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
6 changes: 5 additions & 1 deletion src/action-card/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const InternalActionCard = React.forwardRef(
const headerRowEmpty = !header && !description;

const iconWrapper = icon && (
<div className={clsx(styles.icon, testStyles.icon)} aria-hidden="true">
<div className={clsx(styles.icon, testStyles.icon)} aria-hidden="true" data-awsui-motion-target="">
{icon}
</div>
);
Expand All @@ -104,6 +104,9 @@ const InternalActionCard = React.forwardRef(
onClick: handleButtonClick,
'aria-describedby': ariaDescribedby,
'aria-disabled': disabled || undefined,
// The hover region is the whole card (see the root element); focusing
// the inner control also plays the motion.
'data-awsui-motion-trigger': 'focus',
};

// <a>-tag specific props
Expand Down Expand Up @@ -237,6 +240,7 @@ const InternalActionCard = React.forwardRef(
baseProps.className
)}
aria-disabled={disabled || undefined}
data-awsui-motion-trigger="hover"
>
{standaloneButton}
{contentElement}
Expand Down
2 changes: 2 additions & 0 deletions src/app-layout/toggles/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export const ToggleButton = React.forwardRef(
aria-haspopup={ariaExpanded ? undefined : true}
aria-controls={ariaControls}
data-testid={testId}
data-awsui-motion-trigger="hover"
data-awsui-motion-target=""
>
<InternalIcon svg={iconSvg} name={iconName} badge={badge} />
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ function TriggerButton(
type="button"
data-testid={testId}
data-shift-focus="awsui-layout-drawer-trigger"
data-awsui-motion-trigger="hover"
data-awsui-motion-target=""
{...getAnalyticsMetadataAttribute(triggerEventMetadata)}
>
{customSvg ?? ((iconName || iconSvg) && <Icon name={iconName} svg={iconSvg} />)}
Expand Down
2 changes: 2 additions & 0 deletions src/app-layout/visual-refresh/trigger-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ function TriggerButton(
ref={ref as Ref<HTMLButtonElement>}
type="button"
data-testid={testId}
data-awsui-motion-trigger="hover"
data-awsui-motion-target=""
>
<span className={clsx(badge && clsx(styles['trigger-badge-wrapper'], styles['trigger-button-styles']))}>
{(iconName || iconSvg) && <Icon name={iconName} svg={iconSvg} />}
Expand Down
7 changes: 6 additions & 1 deletion src/breadcrumb-group/all-items-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,13 @@ const getDropdownTrigger =
aria-haspopup={true}
aria-label={ariaLabel}
formAction="none"
data-awsui-motion-trigger="hover"
>
<InternalIcon name="caret-down-filled" className={spinWhenOpen(styles, 'button-icon', isOpen)} />
<InternalIcon
name="caret-down-filled"
className={spinWhenOpen(styles, 'button-icon', isOpen)}
nativeAttributes={{ 'data-awsui-motion-target': '' }}
/>
<span>{currentPage}</span>
</button>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const ExpandableCategoryElement = ({
// tabindex=-1 so we can focus them when necessary.
tabIndex={filteringEnabled ? -1 : highlighted ? 0 : -1}
ref={triggerRef}
data-awsui-motion-trigger="hover"
{...getMenuItemProps({ parent: true, expanded, disabled })}
{...(isDisabledWithReason ? targetProps : {})}
{...getAnalyticsMetadataAttribute(
Expand Down Expand Up @@ -139,6 +140,7 @@ const ExpandableCategoryElement = ({
<InternalIcon
name={isOneTheme ? 'angle-down' : 'caret-down-filled'}
size={isOneTheme ? 'x-small' : 'normal'}
nativeAttributes={{ 'data-awsui-motion-target': '' }}
/>
</span>
</>
Expand Down
22 changes: 22 additions & 0 deletions src/button/__tests__/internal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ test('supports __iconClass property', () => {
expect(container.querySelector(`button .${styles.icon}`)).toHaveClass('example-class');
});

describe('hover-motion attributes', () => {
test('the rendered button is trigger and target, so consumer-provided icons animate too', () => {
const { container } = render(<InternalButton iconName="settings">Label</InternalButton>);
const button = container.querySelector('button')!;
expect(button).toHaveAttribute('data-awsui-motion-trigger', 'hover');
expect(button).toHaveAttribute('data-awsui-motion-target', '');
expect(button.querySelector(`.${styles.icon}`)).toBeTruthy();
});

test('the rendered anchor (link button) is trigger and target', () => {
const { container } = render(
<InternalButton href="#" iconName="settings">
Label
</InternalButton>
);
const anchor = container.querySelector('a')!;
expect(anchor).toHaveAttribute('data-awsui-motion-trigger', 'hover');
expect(anchor).toHaveAttribute('data-awsui-motion-target', '');
expect(anchor.querySelector(`.${styles.icon}`)).toBeTruthy();
});
});

test('sets disabled and does not set aria-disabled, when __focusable is not provided', () => {
const { container } = render(<InternalButton disabled={true} />);

Expand Down
2 changes: 2 additions & 0 deletions src/button/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ export const InternalButton = React.forwardRef(
title: __title ?? ariaLabel,
className: buttonClass,
onClick: handleClick,
'data-awsui-motion-trigger': 'hover',
'data-awsui-motion-target': '',
[DATA_ATTR_FUNNEL_VALUE]: uniqueId,
...getAnalyticsMetadataAttribute(analyticsMetadata),
...getAnalyticsLabelAttribute(shouldHaveContent ? `.${analyticsSelectors.label}` : ''),
Expand Down
2 changes: 2 additions & 0 deletions src/code-editor/tab-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const TabButton = React.forwardRef(
aria-controls={paneId}
aria-hidden={ariaHidden}
aria-label={ariaLabel}
data-awsui-motion-trigger="hover"
data-awsui-motion-target=""
>
<InternalIcon name={iconName} />
<span className={styles.count}>{count}</span>
Expand Down
64 changes: 64 additions & 0 deletions src/expandable-section/__tests__/expandable-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -880,4 +880,68 @@ describe('__expandIconPosition', () => {
expect(tags).toEqual(['A', 'BUTTON']);
});
});

describe('hover-motion trigger/target attributes on the caret', () => {
// The same `icon` element is used across every header shape, so the target attribute must
// land on it regardless of variant/position — this is the "target closest to the icon" rule.
test.each<ExpandableSectionProps.Variant>(['default', 'footer', 'container', 'navigation', 'stacked', 'inline'])(
'the caret icon always carries data-awsui-motion-target for the "%s" variant',
variant => {
const wrapper = renderExpandableSection({ variant, headerText: 'Test Header' });
const targets = wrapper.getElement().querySelectorAll('[data-awsui-motion-target]');
expect(targets.length).toBeGreaterThan(0);
}
);

test('a real caret button (navigation variant) is itself the hover trigger, containing the target', () => {
const wrapper = renderExpandableSection({ variant: 'navigation', headerText: 'Test Header' });
const button = wrapper.getElement().querySelector('button')!;
expect(button).toHaveAttribute('data-awsui-motion-trigger', 'hover');
expect(button.querySelector('[data-awsui-motion-target]')).toBeTruthy();
});

test('default variant with headerText: the whole clickable row is the hover trigger and contains the target', () => {
const wrapper = renderExpandableSection({ variant: 'default', headerText: 'Test Header' });
const triggers = wrapper.getElement().querySelectorAll('[data-awsui-motion-trigger="hover"]');
expect(triggers.length).toBeGreaterThan(0);
const withTarget = Array.from(triggers).some(trigger => trigger.querySelector('[data-awsui-motion-target]'));
expect(withTarget).toBe(true);
});

test('restricted clickable area (headerActions present): the trigger narrows to the header button, still containing the target', () => {
const wrapper = renderExpandableSection({
variant: 'default',
headerText: 'Test Header',
headerActions: <Button>Action</Button>,
});
const headerButton = wrapper.getElement().querySelector('[role="button"]')!;
expect(headerButton).toHaveAttribute('data-awsui-motion-trigger', 'hover');
expect(headerButton.querySelector('[data-awsui-motion-target]')).toBeTruthy();

// The outer row must NOT also claim the trigger — the click handler moved to the header button only.
const outerRow = wrapper.getElement();
expect(outerRow).not.toHaveAttribute('data-awsui-motion-trigger');
});

test('outside-icon case (container variant, end position): the standalone caret button is its own independent trigger', () => {
const { container } = render(
<InternalExpandableSection variant="container" headerText="Test Header" __expandIconPosition="end">
Content
</InternalExpandableSection>
);
const wrapper = createWrapper(container).findExpandableSection()!;
const captions = wrapper.getElement().querySelectorAll('button[data-awsui-motion-trigger="hover"]');
expect(captions.length).toBeGreaterThan(0);
for (const button of Array.from(captions)) {
expect(button.querySelector('[data-awsui-motion-target]')).toBeTruthy();
}
});

test('navigation variant: the whole header row is also a hover trigger, so hovering the adjacent link plays the caret motion', () => {
const wrapper = renderExpandableSection({ variant: 'navigation', headerText: 'Test Header' });
const headerRow = wrapper.getElement().querySelector('[data-awsui-motion-trigger="hover"]:not(button)')!;
expect(headerRow).toBeTruthy();
expect(headerRow.querySelector('[data-awsui-motion-target]')).toBeTruthy();
});
});
});
14 changes: 14 additions & 0 deletions src/expandable-section/expandable-section-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const ExpandIconButton = ({
aria-labelledby={ariaLabelledBy}
aria-controls={ariaControls}
aria-expanded={expanded}
data-awsui-motion-trigger="hover"
onClick={
stopPropagation
? event => {
Expand Down Expand Up @@ -169,6 +170,7 @@ const ExpandableDeprecatedHeader = ({
aria-label={ariaLabel}
aria-controls={ariaControls}
aria-expanded={expanded}
data-awsui-motion-trigger="hover"
{...getExpandActionAnalyticsMetadataAttribute(expanded)}
>
<div className={clsx(styles['icon-container'], styles[`icon-container-${variant}`])}>{icon}</div>
Expand Down Expand Up @@ -210,6 +212,7 @@ const ExpandableNavigationHeader = ({
analyticsSelectors['header-label'],
expandIconPosition === 'end' && styles['header-icon-end']
)}
data-awsui-motion-trigger="hover"
>
{expandIconPosition === 'end' ? (
<>
Expand Down Expand Up @@ -273,6 +276,13 @@ const ExpandableHeaderTextWrapper = ({
// For all other cases, make the entire header clickable for backwards compatibility.
const wrapperListeners = !headerButtonListeners && !headingTagListeners ? listeners : undefined;

// The hover-motion trigger follows whichever element actually owns the click handler above —
// that element is the real interactive surface, so it's also the sane hover boundary for the caret.
const motionTriggerAttribute = { 'data-awsui-motion-trigger': 'hover' } as const;
const headerButtonMotionTrigger = headerButtonListeners ? motionTriggerAttribute : undefined;
const headingTagMotionTrigger = headingTagListeners ? motionTriggerAttribute : undefined;
const wrapperMotionTrigger = wrapperListeners ? motionTriggerAttribute : undefined;

// Standalone caret button — used when the icon is rendered OUTSIDE the headerButton
// (end-position with container or actions). Must be its own <button> for accessibility.
const expandButton = (
Expand Down Expand Up @@ -325,6 +335,7 @@ const ExpandableHeaderTextWrapper = ({
aria-controls={ariaControls}
aria-expanded={expanded}
{...headerButtonListeners}
{...headerButtonMotionTrigger}
{...(headerButtonListeners ? getExpandActionAnalyticsMetadataAttribute(expanded) : {})}
>
{renderIconOutsideHeader ? (
Expand All @@ -347,6 +358,7 @@ const ExpandableHeaderTextWrapper = ({
<div
className={clsx(className, wrapperListeners && styles['click-target'], iconAtEnd && styles['header-icon-end'])}
{...wrapperListeners}
{...wrapperMotionTrigger}
{...(wrapperListeners ? getExpandActionAnalyticsMetadataAttribute(expanded) : {})}
>
{isContainer ? (
Expand All @@ -370,6 +382,7 @@ const ExpandableHeaderTextWrapper = ({
<HeadingTag
className={clsx(styles['header-wrapper'], headingTagListeners && styles['click-target'])}
{...headingTagListeners}
{...headingTagMotionTrigger}
{...(headingTagListeners ? getExpandActionAnalyticsMetadataAttribute(expanded) : {})}
>
{headerButton}
Expand Down Expand Up @@ -412,6 +425,7 @@ export const ExpandableSectionHeader = ({
size={isThemeActive(Theme.OneTheme) ? 'x-small' : variant === 'container' ? 'medium' : 'normal'}
className={clsx(styles.icon, expanded && styles.expanded)}
name={isThemeActive(Theme.OneTheme) ? 'angle-down' : 'caret-down-filled'}
nativeAttributes={{ 'data-awsui-motion-target': '' }}
/>
);
const defaultHeaderProps = {
Expand Down
10 changes: 10 additions & 0 deletions src/flashbar/__tests__/collapsible.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ describe('Collapsible Flashbar', () => {
expect(button).toHaveAttribute('aria-controls', listId);
});

it('hovering the notification bar or focusing the toggle button plays the caret motion', () => {
const flashbar = renderFlashbar();
const button = flashbar.findToggleButton()!.getElement();
const bar = button.closest('[data-awsui-motion-trigger~="hover"]');

expect(bar).toBeTruthy();
expect(button).toHaveAttribute('data-awsui-motion-trigger', 'focus');
expect(button.querySelector('[data-awsui-motion-target]')).toBeTruthy();
});

it('applies aria-describedby attribute to the list, referencing the item counter', () => {
const flashbar = renderFlashbar();
const list = findList(flashbar)!;
Expand Down
9 changes: 8 additions & 1 deletion src/flashbar/collapsible-flashbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte
onClick={toggleCollapseExpand}
ref={notificationBarRef}
style={getNotificationBarStyles(style)}
data-awsui-motion-trigger="hover"
{...getAnalyticsMetadataAttribute({
action: !isFlashbarStackExpanded ? 'expand' : 'collapse',
detail: {
Expand Down Expand Up @@ -389,8 +390,14 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte
aria-expanded={isFlashbarStackExpanded}
aria-label={notificationBarAriaLabel}
className={clsx(styles.button, isFlashbarStackExpanded && styles.expanded)}
data-awsui-motion-trigger="focus"
>
<InternalIcon className={styles.icon} size="normal" name="angle-down" />
<InternalIcon
className={styles.icon}
size="normal"
name="angle-down"
nativeAttributes={{ 'data-awsui-motion-target': '' }}
/>
</button>
</div>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/internal/components/button-trigger/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const ButtonTrigger = (
<button
ref={ref}
{...attributes}
data-awsui-motion-trigger="hover"
{...(disabled || readOnly ? {} : getAnalyticsMetadataAttribute(analyticsMetadata))}
>
{children}
Expand All @@ -137,6 +138,7 @@ const ButtonTrigger = (
name={isThemeActive(Theme.OneTheme) ? 'angle-down' : 'caret-down-filled'}
size={isThemeActive(Theme.OneTheme) ? 'x-small' : 'normal'}
variant={disabled || readOnly ? 'disabled' : 'normal'}
nativeAttributes={{ 'data-awsui-motion-target': '' }}
/>
</span>
)}
Expand Down
3 changes: 3 additions & 0 deletions src/internal/components/drag-handle/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ const DragHandleButton = forwardRef(
onPointerDown={onPointerDown}
onClick={onClick}
onKeyDown={onKeyDown}
{...(variant === 'drag-indicator'
? { 'data-awsui-motion-trigger': 'hover', 'data-awsui-motion-target': '' }
: {})}
>
{/* ensure that events happen on the parent div, not the icon */}
<div className={styles['prevent-pointer']}>
Expand Down
2 changes: 2 additions & 0 deletions src/internal/components/expand-toggle-button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export function ExpandToggleButton({
aria-expanded={isExpanded}
className={clsx(styles['expand-toggle'], disableFocusHighlight && styles['disable-focus-highlight'], className)}
onClick={onExpandableItemToggle}
data-awsui-motion-trigger="hover"
data-awsui-motion-target=""
>
{customIcon ?? (
<InternalIcon
Expand Down
2 changes: 2 additions & 0 deletions src/internal/components/menu-dropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const ButtonTrigger = React.forwardRef(
event.preventDefault();
onClick?.();
}}
data-awsui-motion-trigger="hover"
data-awsui-motion-target=""
>
{hasIcon && (
<InternalIcon
Expand Down
6 changes: 5 additions & 1 deletion src/internal/components/token-list/token-limit-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ export default function TokenLimitToggle({
aria-controls={controlId}
aria-expanded={expanded}
aria-label={ariaLabel}
data-awsui-motion-trigger="hover"
{...getAnalyticsMetadataAttribute(analyticsMetadata)}
>
<InternalIcon name={expanded ? 'treeview-collapse' : 'treeview-expand'} />
<InternalIcon
name={expanded ? 'treeview-collapse' : 'treeview-expand'}
nativeAttributes={{ 'data-awsui-motion-target': '' }}
/>
<span className={styles.description}>{description}</span>
</button>
);
Expand Down
13 changes: 13 additions & 0 deletions src/link/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ describe('Link component', () => {
expect(createWrapper(wrapper.getElement()).find('[aria-label="External link"]')).toBeTruthy();
});

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()).toHaveAttribute('data-awsui-motion-target', '');
});

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();
});
});

describe('i18n', () => {
test('supports providing externalIconAriaLabel through i18n provider', () => {
const { container } = render(
Expand Down
2 changes: 2 additions & 0 deletions src/link/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ const InternalLink = React.forwardRef(
'aria-label': ariaLabel,
'aria-labelledby': undefined as string | undefined,
[DATA_ATTR_FUNNEL_VALUE]: uniqueId,
'data-awsui-motion-trigger': 'hover',
'data-awsui-motion-target': '',
};

if (variant === 'info' && infoLinkLabelFromContext && !ariaLabel) {
Expand Down
Loading
Loading