diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index aefe9e0a61..3d9adbd167 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -5884,6 +5884,13 @@ This property only applies when an \`href\` is provided.", "optional": true, "type": "string", }, + { + "description": "The text displayed in a tooltip on hover or focus. +If \`disabledReason\` is also provided, it takes precedence over \`tooltipText\` when disabled.", + "name": "tooltipText", + "optional": true, + "type": "string", + }, { "defaultValue": "'normal'", "description": "Determines the general styling of the button as follows: @@ -33119,6 +33126,19 @@ The dismiss button is only rendered when the \`dismissible\` property is set to ], }, }, + { + "name": "findTooltip", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "isDisabled", "parameters": [], @@ -36283,6 +36303,22 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ ], }, }, + { + "inheritedFrom": { + "name": "ButtonWrapper.findTooltip", + }, + "name": "findTooltip", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "ButtonWrapper.isDisabled", @@ -45233,6 +45269,14 @@ The dismiss button is only rendered when the \`dismissible\` property is set to "name": "ElementWrapper", }, }, + { + "name": "findTooltip", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "ButtonWrapper", }, @@ -47504,6 +47548,17 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ "name": "ElementWrapper", }, }, + { + "inheritedFrom": { + "name": "ButtonWrapper.findTooltip", + }, + "name": "findTooltip", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "ToggleButtonWrapper", }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 9a46945f90..b1f3565f7c 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -95,6 +95,7 @@ exports[`test-utils selectors 1`] = ` "awsui_content_vjswe", "awsui_disabled-reason-tooltip_1ueyk", "awsui_icon-left_vjswe", + "awsui_tooltip_1ueyk", ], "button-dropdown": [ "awsui_button-dropdown_sne0l", diff --git a/src/button/__tests__/button-tooltip-text.test.tsx b/src/button/__tests__/button-tooltip-text.test.tsx new file mode 100644 index 0000000000..721df39ad7 --- /dev/null +++ b/src/button/__tests__/button-tooltip-text.test.tsx @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; + +import Button, { ButtonProps } from '../../../lib/components/button'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +function renderButton(props: Partial = {}) { + const { container } = render(); + return createWrapper(container).findButton()!; +} + +describe('Button tooltipText', () => { + test('does not show a tooltip by default', () => { + const wrapper = renderButton({ tooltipText: 'Helpful text' }); + expect(wrapper.findTooltip()).toBeNull(); + }); + + test('shows a tooltip on focus', () => { + const wrapper = renderButton({ tooltipText: 'Helpful text' }); + + wrapper.getElement().focus(); + + const tooltip = wrapper.findTooltip(); + expect(tooltip).not.toBeNull(); + expect(tooltip!.getElement()).toHaveTextContent('Helpful text'); + }); + + test('hides the tooltip on blur', () => { + const wrapper = renderButton({ tooltipText: 'Helpful text' }); + + wrapper.getElement().focus(); + expect(wrapper.findTooltip()).not.toBeNull(); + + wrapper.getElement().blur(); + expect(wrapper.findTooltip()).toBeNull(); + }); + + test('shows the tooltip on mouse enter and hides on mouse leave', () => { + const wrapper = renderButton({ tooltipText: 'Helpful text' }); + + fireEvent.mouseEnter(wrapper.getElement()); + expect(wrapper.findTooltip()).not.toBeNull(); + + fireEvent.mouseLeave(wrapper.getElement()); + expect(wrapper.findTooltip()).toBeNull(); + }); + + test('hides the tooltip on Escape key press', () => { + const wrapper = renderButton({ tooltipText: 'Helpful text' }); + + wrapper.getElement().focus(); + expect(wrapper.findTooltip()).not.toBeNull(); + + act(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + + expect(wrapper.findTooltip()).toBeNull(); + }); + + test('does not render a tooltip when tooltipText is not provided', () => { + const wrapper = renderButton(); + + wrapper.getElement().focus(); + fireEvent.mouseEnter(wrapper.getElement()); + + expect(wrapper.findTooltip()).toBeNull(); + }); + + describe('precedence over disabledReason', () => { + test('shows disabledReason instead of tooltipText when disabled with reason', () => { + const wrapper = renderButton({ + disabled: true, + disabledReason: 'Disabled because of reason', + tooltipText: 'Helpful text', + }); + + wrapper.getElement().focus(); + + // disabledReason wins: the disabled-reason tooltip is shown, the tooltipText tooltip is not. + expect(wrapper.findDisabledReason()).not.toBeNull(); + expect(wrapper.findDisabledReason()!.getElement()).toHaveTextContent('Disabled because of reason'); + expect(wrapper.findTooltip()).toBeNull(); + }); + }); + + describe('inactive states', () => { + test('does not show tooltip when button is in loading state', () => { + const wrapper = renderButton({ loading: true, tooltipText: 'Helpful text' }); + + wrapper.getElement().focus(); + fireEvent.mouseEnter(wrapper.getElement()); + + expect(wrapper.findTooltip()).toBeNull(); + }); + + test('does not show tooltip when button is disabled without reason', () => { + const wrapper = renderButton({ disabled: true, tooltipText: 'Helpful text' }); + + // Disabled buttons can't be focused, but we still verify hover does nothing. + fireEvent.mouseEnter(wrapper.getElement()); + + expect(wrapper.findTooltip()).toBeNull(); + }); + }); + + describe('with href', () => { + test('shows a tooltip on focus for a link button', () => { + const { container } = render( + + ); + const wrapper = createWrapper(container).findButton()!; + + wrapper.getElement().focus(); + expect(wrapper.findTooltip()).not.toBeNull(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Open settings'); + }); + }); +}); diff --git a/src/button/index.tsx b/src/button/index.tsx index 408a0db0e8..89c45e31fe 100644 --- a/src/button/index.tsx +++ b/src/button/index.tsx @@ -39,6 +39,7 @@ const Button = React.forwardRef( ariaExpanded, ariaHaspopup, ariaControls, + tooltipText, fullWidth, form, i18nStrings, @@ -83,6 +84,7 @@ const Button = React.forwardRef( ariaExpanded={ariaExpanded} ariaHaspopup={ariaHaspopup} ariaControls={ariaControls} + tooltipText={tooltipText} fullWidth={fullWidth} form={form} i18nStrings={i18nStrings} diff --git a/src/button/interfaces.ts b/src/button/interfaces.ts index d89686e680..9da54b1557 100644 --- a/src/button/interfaces.ts +++ b/src/button/interfaces.ts @@ -207,6 +207,12 @@ export interface ButtonProps extends BaseComponentProps, BaseButtonProps { */ variant?: ButtonProps.Variant; + /** + * The text displayed in a tooltip on hover or focus. + * If `disabledReason` is also provided, it takes precedence over `tooltipText` when disabled. + */ + tooltipText?: string; + /** * Specifies alternate text for a custom icon. We recommend that you provide this for accessibility. * This property is ignored if you use a predefined icon or if you set your custom icon using the `iconSvg` slot. diff --git a/src/button/internal.tsx b/src/button/internal.tsx index 8ef126bf7b..d752580d51 100644 --- a/src/button/internal.tsx +++ b/src/button/internal.tsx @@ -87,6 +87,7 @@ export const InternalButton = React.forwardRef( ariaExpanded, ariaHaspopup, ariaControls, + tooltipText, fullWidth, badge, i18nStrings, @@ -113,6 +114,10 @@ export const InternalButton = React.forwardRef( const isDisabledWithReason = (variant === 'normal' || variant === 'primary' || variant === 'icon') && !!disabledReason && disabled; + // tooltipText is shown on hover/focus when the button is fully interactive. When + // the button is disabled-with-reason, disabledReason takes precedence. + const hasTooltipText = !!tooltipText && !isNotInteractive && !isDisabledWithReason; + const hasAriaDisabled = (loading && !disabled) || (disabled && __focusable) || isDisabledWithReason; const shouldHaveContent = children && @@ -293,6 +298,24 @@ export const InternalButton = React.forwardRef( ); + const tooltipTextProps = + hasTooltipText && !(disabled && !!disabledReason) + ? { + onFocus: () => setShowTooltip(true), + onBlur: () => setShowTooltip(false), + onMouseEnter: () => setShowTooltip(true), + onMouseLeave: () => setShowTooltip(false), + } + : {}; + const tooltipTextContent = hasTooltipText && showTooltip && ( + buttonRef.current} + content={tooltipText!} + onEscape={() => setShowTooltip(false)} + /> + ); + const stylePropertiesAndVariables = getButtonStyles(style); if (isAnchor) { @@ -310,6 +333,7 @@ export const InternalButton = React.forwardRef( > {...buttonProps} {...disabledReasonProps} + {...tooltipTextProps} tag="a" componentName="Button" nativeAttributes={nativeAnchorAttributes} @@ -326,6 +350,7 @@ export const InternalButton = React.forwardRef( > {buttonContent} {isDisabledWithReason && disabledReasonContent} + {tooltipTextContent} {loading && loadingText && (