From 0d0aeef939702a241515bb99cbc93d004ce130d2 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Fri, 4 Sep 2026 16:45:29 +0200 Subject: [PATCH 1/2] feat: add motion trigger and target attributes --- src/action-card/internal.tsx | 6 +- src/app-layout/toggles/index.tsx | 2 + .../toolbar/trigger-button/index.tsx | 2 + .../visual-refresh/trigger-button.tsx | 2 + src/breadcrumb-group/all-items-dropdown.tsx | 7 +- .../expandable-category-element.tsx | 2 + src/button/__tests__/internal.test.tsx | 22 +++++++ src/button/internal.tsx | 2 + src/code-editor/tab-button.tsx | 2 + .../__tests__/expandable-section.test.tsx | 64 +++++++++++++++++++ .../expandable-section-header.tsx | 14 ++++ src/flashbar/__tests__/collapsible.test.tsx | 10 +++ src/flashbar/collapsible-flashbar.tsx | 9 ++- .../components/button-trigger/index.tsx | 2 + .../components/drag-handle/button.tsx | 3 + .../components/expand-toggle-button/index.tsx | 2 + .../components/menu-dropdown/index.tsx | 2 + .../token-list/token-limit-toggle.tsx | 6 +- src/link/__tests__/index.test.tsx | 13 ++++ src/link/internal.tsx | 3 +- src/pagination/internal.tsx | 13 +++- .../__tests__/filtering-token.test.tsx | 7 ++ src/property-filter/filtering-token/index.tsx | 6 +- src/segmented-control/segment.tsx | 2 + src/select/__tests__/trigger.test.tsx | 6 ++ .../__tests__/side-navigation.test.tsx | 40 ++++++++++++ src/side-navigation/parts.tsx | 8 ++- src/table/body-cell/index.tsx | 3 +- src/table/header-cell/index.tsx | 7 +- src/tabs/tab-header-bar.tsx | 9 ++- src/token/__tests__/token.test.tsx | 7 ++ src/token/dismiss-button.tsx | 7 +- .../parts/overflow-menu/menu-item.tsx | 7 +- src/top-navigation/parts/utility.tsx | 4 +- 34 files changed, 283 insertions(+), 18 deletions(-) diff --git a/src/action-card/internal.tsx b/src/action-card/internal.tsx index cca176a92f..3b7b8206b1 100644 --- a/src/action-card/internal.tsx +++ b/src/action-card/internal.tsx @@ -79,7 +79,7 @@ const InternalActionCard = React.forwardRef( const headerRowEmpty = !header && !description; const iconWrapper = icon && ( - )} diff --git a/src/internal/components/button-trigger/index.tsx b/src/internal/components/button-trigger/index.tsx index abc46867bc..462a77c0fe 100644 --- a/src/internal/components/button-trigger/index.tsx +++ b/src/internal/components/button-trigger/index.tsx @@ -128,6 +128,7 @@ const ButtonTrigger = ( ); diff --git a/src/link/__tests__/index.test.tsx b/src/link/__tests__/index.test.tsx index f033577bf9..0998069ba0 100644 --- a/src/link/__tests__/index.test.tsx +++ b/src/link/__tests__/index.test.tsx @@ -47,6 +47,19 @@ describe('Link component', () => { expect(createWrapper(wrapper.getElement()).find('[aria-label="External link"]')).toBeTruthy(); }); + describe('external icon hover motion', () => { + test('external link is a hover trigger and its icon carries the motion target', () => { + const wrapper = renderLink({ href: '#', external: true, children: 'External' }); + expect(wrapper.getElement()).toHaveAttribute('data-awsui-motion-trigger', 'hover'); + expect(wrapper.getElement().querySelector('[data-awsui-motion-target]')).toBeTruthy(); + }); + + test('non-external link has no motion trigger', () => { + const wrapper = renderLink({ href: '#', children: 'Internal' }); + expect(wrapper.getElement()).not.toHaveAttribute('data-awsui-motion-trigger'); + }); + }); + describe('i18n', () => { test('supports providing externalIconAriaLabel through i18n provider', () => { const { container } = render( diff --git a/src/link/internal.tsx b/src/link/internal.tsx index 74f4bfe670..7fa3a364e7 100644 --- a/src/link/internal.tsx +++ b/src/link/internal.tsx @@ -182,6 +182,7 @@ const InternalLink = React.forwardRef( 'aria-label': ariaLabel, 'aria-labelledby': undefined as string | undefined, [DATA_ATTR_FUNNEL_VALUE]: uniqueId, + ...(external ? { 'data-awsui-motion-trigger': 'hover' } : {}), }; if (variant === 'info' && infoLinkLabelFromContext && !ariaLabel) { @@ -200,7 +201,7 @@ const InternalLink = React.forwardRef( aria-label={renderedExternalIconAriaLabel} role={renderedExternalIconAriaLabel ? 'img' : undefined} > - + )} diff --git a/src/pagination/internal.tsx b/src/pagination/internal.tsx index 413b01b2d3..f83debe7c5 100644 --- a/src/pagination/internal.tsx +++ b/src/pagination/internal.tsx @@ -70,6 +70,7 @@ function PageButton({ tabIndex={disabled ? -1 : 0} onClick={handleClick} aria-current={isCurrent} + data-awsui-motion-trigger="hover" {...(disabled ? {} : getAnalyticsMetadataAttribute({ @@ -259,7 +260,11 @@ const InternalPagination = React.forwardRef( }, }))} > - + {pagesVariant === 'compact' ? (
  • @@ -317,7 +322,11 @@ const InternalPagination = React.forwardRef( }, }))} > - + {jumpToPage && (
  • diff --git a/src/property-filter/filtering-token/__tests__/filtering-token.test.tsx b/src/property-filter/filtering-token/__tests__/filtering-token.test.tsx index aa6cd76841..9e7c1c86c4 100644 --- a/src/property-filter/filtering-token/__tests__/filtering-token.test.tsx +++ b/src/property-filter/filtering-token/__tests__/filtering-token.test.tsx @@ -84,6 +84,13 @@ test('renders a single token as role="group" with token ARIA label and dismiss b expect(token.findTokenOperation()!).toBeNull(); }); +test('the dismiss button is a hover-motion trigger and its close icon carries the motion target', () => { + const token = renderToken({ tokens: [token1] }); + const removeButton = token.findRemoveButton()!.getElement(); + expect(removeButton).toHaveAttribute('data-awsui-motion-trigger', 'hover'); + expect(removeButton.querySelector('[data-awsui-motion-target]')).toBeTruthy(); +}); + test('renders 3 tokens as role="group" with group ARIA label no dismiss button', () => { const token = renderToken({ tokens: [token1, token2, token3], groupAriaLabel: 'filter group with 3 tokens' }); expect(token.getElement()).toHaveAttribute('role', 'group'); diff --git a/src/property-filter/filtering-token/index.tsx b/src/property-filter/filtering-token/index.tsx index a631178e3d..5e0cabb6d6 100644 --- a/src/property-filter/filtering-token/index.tsx +++ b/src/property-filter/filtering-token/index.tsx @@ -340,9 +340,10 @@ function TokenDismissButton({ aria-label={ariaLabel} onClick={onClick} disabled={disabled} + data-awsui-motion-trigger="hover" {...getAnalyticsMetadataAttribute({ action: 'dismiss' })} > - + ); } @@ -354,8 +355,9 @@ function TokenEditButton({ ariaLabel, disabled }: { ariaLabel: string; disabled? className={clsx(styles['edit-button'], testUtilStyles['filtering-token-edit-button'])} aria-label={ariaLabel} disabled={disabled} + data-awsui-motion-trigger="hover" > - + ); } diff --git a/src/segmented-control/segment.tsx b/src/segmented-control/segment.tsx index 035204c0fa..2388bdc39b 100644 --- a/src/segmented-control/segment.tsx +++ b/src/segmented-control/segment.tsx @@ -71,6 +71,8 @@ export const Segment = React.forwardRef( onMouseLeave={isDisabledWithReason ? () => setShowTooltip(false) : undefined} {...(isDisabledWithReason ? targetProps : {})} data-testid={id} + data-awsui-motion-trigger="hover" + data-awsui-motion-target="" style={getSegmentedControlSegmentStyles(style)} > {(iconName || iconUrl || iconSvg) && ( diff --git a/src/select/__tests__/trigger.test.tsx b/src/select/__tests__/trigger.test.tsx index 571ae3d19c..e8f557a942 100644 --- a/src/select/__tests__/trigger.test.tsx +++ b/src/select/__tests__/trigger.test.tsx @@ -34,6 +34,12 @@ const defaultProps: any = { }; describe('Trigger component', () => { + test('is a hover-motion trigger and its caret icon carries the motion target', () => { + const wrapper = renderComponent(defaultProps); + expect(wrapper.getElement()).toHaveAttribute('data-awsui-motion-trigger', 'hover'); + expect(wrapper.getElement().querySelector('[data-awsui-motion-target]')).toBeTruthy(); + }); + describe('Empty state', () => { const wrapper = renderComponent(defaultProps); const buttonTriggerEl = wrapper.getElement(); diff --git a/src/side-navigation/__tests__/side-navigation.test.tsx b/src/side-navigation/__tests__/side-navigation.test.tsx index ec35b3a89b..bb2020dcd9 100644 --- a/src/side-navigation/__tests__/side-navigation.test.tsx +++ b/src/side-navigation/__tests__/side-navigation.test.tsx @@ -273,6 +273,46 @@ describe('SideNavigation', () => { expect(createWrapper(wrapper.getElement()).find('[role="img"][aria-label="External link"]')).toBeTruthy(); }); + it('external link is a hover-motion trigger and its icon carries the motion target', () => { + const wrapper = renderSideNavigation({ + items: [{ type: 'link', text: 'Page 1', href: '#something', external: true }], + }); + const externalLink = wrapper.findItemByIndex(1)!.findLink()!.getElement(); + + expect(externalLink).toHaveAttribute('data-awsui-motion-trigger', 'hover'); + expect(externalLink.querySelector('[data-awsui-motion-target]')).toBeTruthy(); + }); + + it('link with an icon slot is a hover-motion trigger and the icon wrapper is the target', () => { + const wrapper = renderSideNavigation({ + items: [{ type: 'link', text: 'Page 1', href: '#something', icon: }], + }); + const link = wrapper.findItemByIndex(1)!.findLink()!.getElement(); + + expect(link).toHaveAttribute('data-awsui-motion-trigger', 'hover'); + // Target sits on the wrapper (not the icon itself) so that slot-provided icons animate too. + const target = link.querySelector('[data-awsui-motion-target]')!; + expect(target.querySelector('[data-testid="custom-icon"]')).toBeTruthy(); + }); + + it('expandable link group header with an icon slot is a hover-motion trigger containing the target', () => { + const wrapper = renderSideNavigation({ + items: [ + { + type: 'expandable-link-group', + text: 'Group', + href: '#group', + icon: , + items: [{ type: 'link', text: 'Child', href: '#child' }], + }, + ], + }); + const header = wrapper.getElement().querySelector('a[href="#group"]')!; + + expect(header).toHaveAttribute('data-awsui-motion-trigger', 'hover'); + expect(header.querySelector('[data-awsui-motion-target]')).toBeTruthy(); + }); + it('has an additional info when "info" property is specified', () => { const wrapper = renderSideNavigation({ items: [{ type: 'link', text: 'Page 1', href: '#something', info: Additional info }], diff --git a/src/side-navigation/parts.tsx b/src/side-navigation/parts.tsx index a8c3934159..02b7c93891 100644 --- a/src/side-navigation/parts.tsx +++ b/src/side-navigation/parts.tsx @@ -413,6 +413,7 @@ const ItemIcon = React.forwardRef(function ItemI collapsed && styles['item-icon--collapsed'], className )} + data-awsui-motion-target="" {...rest} > {icon} @@ -527,6 +528,7 @@ function Link({ definition, activeHref, fireFollow, position, collapsed, activeT rel={definition.external ? 'noopener noreferrer' : undefined} aria-current={definition.href === activeHref ? 'page' : undefined} aria-label={collapsed ? definition.text : undefined} + {...(definition.external || definition.icon ? { 'data-awsui-motion-trigger': 'hover' } : {})} onClick={onClick} {...(collapsed ? collapsedTooltip.triggerProps : {})} {...getAnalyticsMetadataAttribute(clickActionAnalyticsMetadata)} @@ -540,7 +542,11 @@ function Link({ definition, activeHref, fireFollow, position, collapsed, activeT {definition.text} {definition.external && ( - + )} diff --git a/src/table/body-cell/index.tsx b/src/table/body-cell/index.tsx index 23e7da4499..29be28ca7b 100644 --- a/src/table/body-cell/index.tsx +++ b/src/table/body-cell/index.tsx @@ -126,9 +126,10 @@ function TableCellEditable({ ref={editActivateRef} onClick={!isEditing && isExpandableColumn ? onEditStart : undefined} tabIndex={editActivateTabIndex} + data-awsui-motion-trigger="hover" > - + diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index 62e4c45d12..75c1e9e0fe 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -271,6 +271,7 @@ export function TableHeaderCell({ tabIndex: clickableHeaderTabIndex, role: 'button', onClick: event => handleClick(event.shiftKey), + 'data-awsui-motion-trigger': 'hover', // Prevent the browser from extending the text selection on Shift+click. onMouseDown: (event: React.MouseEvent) => { if (event.shiftKey) { @@ -297,6 +298,7 @@ export function TableHeaderCell({ 'columnDefinitions.editConfig.editIconAriaLabel', column.editConfig?.editIconAriaLabel )} + nativeAttributes={{ 'data-awsui-motion-target': '' }} /> ) : null} @@ -309,7 +311,10 @@ export function TableHeaderCell({ {multiSortIndex} )} - + )} diff --git a/src/tabs/tab-header-bar.tsx b/src/tabs/tab-header-bar.tsx index 014ada060a..c7e2de6d41 100644 --- a/src/tabs/tab-header-bar.tsx +++ b/src/tabs/tab-header-bar.tsx @@ -619,11 +619,16 @@ const TabTrigger = forwardRef( }; return tab.href ? ( - + {children} ) : ( - ); diff --git a/src/token/__tests__/token.test.tsx b/src/token/__tests__/token.test.tsx index 53d6069da3..0808b3b842 100644 --- a/src/token/__tests__/token.test.tsx +++ b/src/token/__tests__/token.test.tsx @@ -108,6 +108,13 @@ describe('Token', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); + test('is a hover-motion trigger and its close icon carries the motion target', () => { + const wrapper = renderToken({ label: 'Test token', onDismiss: jest.fn() }); + const dismiss = wrapper.findDismiss()!.getElement(); + expect(dismiss).toHaveAttribute('data-awsui-motion-trigger', 'hover'); + expect(dismiss.querySelector('[data-awsui-motion-target]')).toBeTruthy(); + }); + test('shows for inline readonly tokens', () => { const onDismiss = jest.fn(); const wrapper = renderToken({ diff --git a/src/token/dismiss-button.tsx b/src/token/dismiss-button.tsx index 1b42412dba..f751760eb6 100644 --- a/src/token/dismiss-button.tsx +++ b/src/token/dismiss-button.tsx @@ -55,9 +55,14 @@ function DismissButton( fireNonCancelableEvent(onDismiss); }} aria-label={dismissLabel} + data-awsui-motion-trigger="hover" {...(disabled || readOnly ? {} : getAnalyticsMetadataAttribute(analyticsMetadata))} > - + ); } diff --git a/src/top-navigation/parts/overflow-menu/menu-item.tsx b/src/top-navigation/parts/overflow-menu/menu-item.tsx index 3cafabe5b6..2b4fe958eb 100644 --- a/src/top-navigation/parts/overflow-menu/menu-item.tsx +++ b/src/top-navigation/parts/overflow-menu/menu-item.tsx @@ -59,6 +59,7 @@ const LinkItem = forwardRef( href={href} target={anchorTarget} rel={anchorRel} + data-awsui-motion-trigger="hover" {...(testId ? { 'data-testid': testId } : {})} > @@ -83,6 +84,7 @@ const ButtonItem = forwardRef( ref={ref} className={styles['overflow-menu-control']} onClick={onClick} + data-awsui-motion-trigger="hover" {...(typeof testId === 'string' ? { 'data-testid': testId } : {})} > @@ -109,7 +111,7 @@ const NavigationItem = forwardRef( } + endIcon={} testId={testId} onClick={() => navigate('dropdown-menu', { @@ -139,11 +141,12 @@ const ExpandableItem: React.FC< className={clsx(styles['overflow-menu-control'], styles['overflow-menu-control-expandable-menu-trigger'])} onClick={() => setExpanded(value => !value)} aria-expanded={expanded} + data-awsui-motion-trigger="hover" > - + } > diff --git a/src/top-navigation/parts/utility.tsx b/src/top-navigation/parts/utility.tsx index 6fc0df1003..da82b73cca 100644 --- a/src/top-navigation/parts/utility.tsx +++ b/src/top-navigation/parts/utility.tsx @@ -58,7 +58,7 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr aria-label={definition.externalIconAriaLabel} role={definition.externalIconAriaLabel ? 'img' : undefined} > - + )} @@ -90,6 +90,8 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr ); }} ariaLabel={ariaLabel} + // Target on the link so that consumer-provided utility icons animate too. + nativeAttributes={{ 'data-awsui-motion-trigger': 'hover', 'data-awsui-motion-target': '' }} > {hasIcon && ( Date: Fri, 4 Sep 2026 17:24:58 +0200 Subject: [PATCH 2/2] refactor: move motion trigger to InternalLink --- src/link/__tests__/index.test.tsx | 14 +++++++------- src/link/internal.tsx | 5 +++-- src/top-navigation/parts/utility.tsx | 4 +--- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/link/__tests__/index.test.tsx b/src/link/__tests__/index.test.tsx index 0998069ba0..e2f404f153 100644 --- a/src/link/__tests__/index.test.tsx +++ b/src/link/__tests__/index.test.tsx @@ -47,16 +47,16 @@ describe('Link component', () => { expect(createWrapper(wrapper.getElement()).find('[aria-label="External link"]')).toBeTruthy(); }); - describe('external icon hover motion', () => { - test('external link is a hover trigger and its icon carries the motion target', () => { - const wrapper = renderLink({ href: '#', external: true, children: 'External' }); + describe('icon hover motion', () => { + test('every link is hover-motion trigger and target', () => { + const wrapper = renderLink({ href: '#', children: 'Internal' }); expect(wrapper.getElement()).toHaveAttribute('data-awsui-motion-trigger', 'hover'); - expect(wrapper.getElement().querySelector('[data-awsui-motion-target]')).toBeTruthy(); + expect(wrapper.getElement()).toHaveAttribute('data-awsui-motion-target', ''); }); - test('non-external link has no motion trigger', () => { - const wrapper = renderLink({ href: '#', children: 'Internal' }); - expect(wrapper.getElement()).not.toHaveAttribute('data-awsui-motion-trigger'); + test('the external icon sits inside the link and needs no own target', () => { + const wrapper = renderLink({ href: '#', external: true, children: 'External' }); + expect(wrapper.getElement().querySelector(`.${styles['icon-wrapper']}`)).toBeTruthy(); }); }); diff --git a/src/link/internal.tsx b/src/link/internal.tsx index 7fa3a364e7..2aa0a80fec 100644 --- a/src/link/internal.tsx +++ b/src/link/internal.tsx @@ -182,7 +182,8 @@ const InternalLink = React.forwardRef( 'aria-label': ariaLabel, 'aria-labelledby': undefined as string | undefined, [DATA_ATTR_FUNNEL_VALUE]: uniqueId, - ...(external ? { 'data-awsui-motion-trigger': 'hover' } : {}), + 'data-awsui-motion-trigger': 'hover', + 'data-awsui-motion-target': '', }; if (variant === 'info' && infoLinkLabelFromContext && !ariaLabel) { @@ -201,7 +202,7 @@ const InternalLink = React.forwardRef( aria-label={renderedExternalIconAriaLabel} role={renderedExternalIconAriaLabel ? 'img' : undefined} > - + )} diff --git a/src/top-navigation/parts/utility.tsx b/src/top-navigation/parts/utility.tsx index da82b73cca..6fc0df1003 100644 --- a/src/top-navigation/parts/utility.tsx +++ b/src/top-navigation/parts/utility.tsx @@ -58,7 +58,7 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr aria-label={definition.externalIconAriaLabel} role={definition.externalIconAriaLabel ? 'img' : undefined} > - + )} @@ -90,8 +90,6 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr ); }} ariaLabel={ariaLabel} - // Target on the link so that consumer-provided utility icons animate too. - nativeAttributes={{ 'data-awsui-motion-trigger': 'hover', 'data-awsui-motion-target': '' }} > {hasIcon && (