diff --git a/CHANGELOG.md b/CHANGELOG.md index f350e8a8b8..8ba58659c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `alert`, `info`, `share-alt` and `microsoft-stream` icons to Teams theme @marst89 ([#1544](https://github.com/stardust-ui/react/pull/1544)) - Add `custom` `kind` for `items` in `Toolbar` component @miroslavstastny ([#1558](https://github.com/stardust-ui/react/pull/1558)) - Add `hand` icon to Teams theme @t-proko ([#1567](https://github.com/stardust-ui/react/pull/1567)) +- Add accessibility attributes and keyboard handlers for `Tooltip` @sophieH29 ([#1575](https://github.com/stardust-ui/react/pull/1575)) ### Documentation - Ensure docs content doesn't overlap with sidebar @kuzhelov ([#1568](https://github.com/stardust-ui/react/pull/1568)) diff --git a/docs/src/examples/components/Tooltip/Types/TooltipExample.shorthand.tsx b/docs/src/examples/components/Tooltip/Types/TooltipExample.shorthand.tsx index f2df6671eb..a2d9fda391 100644 --- a/docs/src/examples/components/Tooltip/Types/TooltipExample.shorthand.tsx +++ b/docs/src/examples/components/Tooltip/Types/TooltipExample.shorthand.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Button, Tooltip } from '@stardust-ui/react' const TooltipExample = () => ( - } content="Hello from tooltip!" /> + } content="Hello from tooltip!" /> ) export default TooltipExample diff --git a/docs/src/examples/components/Tooltip/Types/TooltipExample.tsx b/docs/src/examples/components/Tooltip/Types/TooltipExample.tsx index 5a1c4b42bc..6de5f89ff3 100644 --- a/docs/src/examples/components/Tooltip/Types/TooltipExample.tsx +++ b/docs/src/examples/components/Tooltip/Types/TooltipExample.tsx @@ -3,7 +3,7 @@ import { Button, Tooltip } from '@stardust-ui/react' const TooltipExample = () => ( - ) diff --git a/packages/react/src/components/Dialog/Dialog.tsx b/packages/react/src/components/Dialog/Dialog.tsx index f457772a28..cea2ec0b33 100644 --- a/packages/react/src/components/Dialog/Dialog.tsx +++ b/packages/react/src/components/Dialog/Dialog.tsx @@ -12,6 +12,7 @@ import { AutoControlledComponent, doesNodeContainClick, applyAccessibilityKeyHandlers, + getOrGenerateIdFromShorthand, } from '../../lib' import { dialogBehavior } from '../../lib/accessibility' import { FocusTrapZoneProps } from '../../lib/accessibility/FocusZone' @@ -23,26 +24,6 @@ import Header from '../Header/Header' import Portal from '../Portal/Portal' import Flex from '../Flex/Flex' -const getOrGenerateIdFromShorthand = ( - slotName: string, - value: ShorthandValue, - currentValue?: string, -): string | undefined => { - if (_.isNil(value)) { - return undefined - } - - if (React.isValidElement(value)) { - return (value as React.ReactElement<{ id?: string }>).props.id - } - - if (_.isPlainObject(value)) { - return (value as Record).id - } - - return currentValue || _.uniqueId(`dialog-${slotName}-`) -} - export interface DialogSlotClassNames { header: string content: string @@ -174,8 +155,8 @@ class Dialog extends AutoControlledComponent, DialogStat state: DialogState, ): Partial { return { - contentId: getOrGenerateIdFromShorthand('content', props.content, state.contentId), - headerId: getOrGenerateIdFromShorthand('header', props.header, state.headerId), + contentId: getOrGenerateIdFromShorthand('dialog-content-', props.content, state.contentId), + headerId: getOrGenerateIdFromShorthand('dialog-header-', props.header, state.headerId), } } diff --git a/packages/react/src/components/Tooltip/Tooltip.tsx b/packages/react/src/components/Tooltip/Tooltip.tsx index 7bf45f62e5..42437b77bb 100644 --- a/packages/react/src/components/Tooltip/Tooltip.tsx +++ b/packages/react/src/components/Tooltip/Tooltip.tsx @@ -17,6 +17,7 @@ import { commonPropTypes, isFromKeyboard, setWhatInputSource, + getOrGenerateIdFromShorthand, } from '../../lib' import { ShorthandValue, Props } from '../../types' import { @@ -27,6 +28,7 @@ import { PopperChildrenProps, } from '../../lib/positioner' import TooltipContent from './TooltipContent' +import { tooltipBehavior } from '../../lib/accessibility' import { Accessibility } from '../../lib/accessibility/types' import { ReactAccessibilityBehavior } from '../../lib/accessibility/reactTypes' @@ -36,6 +38,7 @@ export interface TooltipSlotClassNames { export interface TooltipState { open: boolean + contentId: string } export interface TooltipProps @@ -116,6 +119,7 @@ export default class Tooltip extends AutoControlledComponent() triggerRef = React.createRef() contentRef = React.createRef() - closeTimeoutId + actionHandlers = { + close: e => { + this.setTooltipOpen(false, e) + e.stopPropagation() + e.preventDefault() + }, + } + + static getAutoControlledStateFromProps( + props: TooltipProps, + state: TooltipState, + ): Partial { + return { + contentId: getOrGenerateIdFromShorthand('tooltip-content-', props.content, state.contentId), + } + } + renderComponent({ classes, rtl, diff --git a/packages/react/src/lib/accessibility/Behaviors/Tooltip/tooltipBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Tooltip/tooltipBehavior.ts new file mode 100644 index 0000000000..d43138b4d7 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Tooltip/tooltipBehavior.ts @@ -0,0 +1,55 @@ +import { Accessibility } from '../../types' +import * as keyboardKey from 'keyboard-key' + +/** + * @description + * Implements ARIA Tooltip design pattern. + * + * @specification + * Adds attribute 'role=tooltip' to 'tooltip' slot. + * Adds attribute 'aria-hidden=false' to 'tooltip' slot if 'open' property is true. Sets the attribute to 'true' otherwise. + * Adds attribute 'aria-describedby' based on the property 'aria-describedby' to 'trigger' slot. + * Triggers 'close' action with 'Escape' on 'trigger'. + */ +const tooltipBehavior: Accessibility = props => { + const defaultAriaDescribedBy = getDefaultAriaDescribedBy(props) + + return { + attributes: { + trigger: { + 'aria-describedby': defaultAriaDescribedBy || props['aria-describedby'], + }, + tooltip: { + role: 'tooltip', + id: defaultAriaDescribedBy, + 'aria-hidden': !props.open, + }, + }, + keyActions: { + trigger: { + close: { + keyCombinations: [{ keyCode: keyboardKey.Escape }], + }, + }, + }, + } +} + +export default tooltipBehavior + +/** + * Returns the element id of the tooltip, it is used when user does not provide aria-describedby as props. + */ +const getDefaultAriaDescribedBy = (props: TooltipBehaviorProps) => { + if (props['aria-describedby']) { + return undefined + } + return props.contentId +} + +export type TooltipBehaviorProps = { + /** If tooltip is visible. */ + open: boolean + /** Tooltip's container id. */ + contentId: string +} diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts index c2970f16cf..3e5d6b26de 100644 --- a/packages/react/src/lib/accessibility/index.ts +++ b/packages/react/src/lib/accessibility/index.ts @@ -49,3 +49,4 @@ export { default as accordionBehavior } from './Behaviors/Accordion/accordionBeh export { default as accordionTitleBehavior } from './Behaviors/Accordion/accordionTitleBehavior' export { default as accordionContentBehavior } from './Behaviors/Accordion/accordionContentBehavior' export { default as checkboxBehavior } from './Behaviors/Checkbox/checkboxBehavior' +export { default as tooltipBehavior } from './Behaviors/Tooltip/tooltipBehavior' diff --git a/packages/react/src/lib/getOrGenerateIdFromShorthand.ts b/packages/react/src/lib/getOrGenerateIdFromShorthand.ts new file mode 100644 index 0000000000..875dce59b4 --- /dev/null +++ b/packages/react/src/lib/getOrGenerateIdFromShorthand.ts @@ -0,0 +1,25 @@ +import * as React from 'react' +import * as _ from 'lodash' +import { ShorthandValue } from '../types' + +const getOrGenerateIdFromShorthand = ( + prefix: string, + value: ShorthandValue, + currentValue?: string, +): string | undefined => { + if (_.isNil(value)) { + return undefined + } + + if (React.isValidElement(value)) { + return (value as React.ReactElement<{ id?: string }>).props.id + } + + if (_.isPlainObject(value)) { + return (value as Record).id + } + + return currentValue || _.uniqueId(prefix) +} + +export default getOrGenerateIdFromShorthand diff --git a/packages/react/src/lib/index.ts b/packages/react/src/lib/index.ts index 63095471cd..a5f6ef3eda 100644 --- a/packages/react/src/lib/index.ts +++ b/packages/react/src/lib/index.ts @@ -8,6 +8,7 @@ export { default as felaRenderer } from './felaRenderer' export { default as toCompactArray } from './toCompactArray' export { default as rtlTextContainer } from './rtlTextContainer' export { default as stringLiteralsArray } from './stringLiteralsArray' +export { default as getOrGenerateIdFromShorthand } from './getOrGenerateIdFromShorthand' export * from './factories' export { default as callable } from './callable' diff --git a/packages/react/test/specs/behaviors/behavior-test.tsx b/packages/react/test/specs/behaviors/behavior-test.tsx index ccb2d0ba3e..dd800b5f66 100644 --- a/packages/react/test/specs/behaviors/behavior-test.tsx +++ b/packages/react/test/specs/behaviors/behavior-test.tsx @@ -48,6 +48,7 @@ import { toolbarItemBehavior, toolbarRadioGroupBehavior, toolbarRadioGroupItemBehavior, + tooltipBehavior, } from 'src/lib/accessibility' import { TestHelper } from './testHelper' import definitions from './testDefinitions' @@ -100,5 +101,6 @@ testHelper.addBehavior('toolbarBehavior', toolbarBehavior) testHelper.addBehavior('toolbarItemBehavior', toolbarItemBehavior) testHelper.addBehavior('toolbarRadioGroupBehavior', toolbarRadioGroupBehavior) testHelper.addBehavior('toolbarRadioGroupItemBehavior', toolbarRadioGroupItemBehavior) +testHelper.addBehavior('tooltipBehavior', tooltipBehavior) testHelper.run(behaviorMenuItems) diff --git a/packages/react/test/specs/components/Tooltip/Tooltip-test.tsx b/packages/react/test/specs/components/Tooltip/Tooltip-test.tsx index 6e528389e6..d91cc65ecc 100644 --- a/packages/react/test/specs/components/Tooltip/Tooltip-test.tsx +++ b/packages/react/test/specs/components/Tooltip/Tooltip-test.tsx @@ -1,15 +1,38 @@ import * as React from 'react' import Tooltip from 'src/components/Tooltip/Tooltip' +import Button from 'src/components/Button/Button' -import { mountWithProvider } from '../../../utils' +import { mountWithProvider, findIntrinsicElement } from '../../../utils' describe('Tooltip', () => { + describe('content', () => { + it('uses "id" if "content" with "id" is passed', () => { + const contentId = 'element-id' + + const wrapper = mountWithProvider( + } content={{ id: contentId }} />, + ) + const content = findIntrinsicElement(wrapper, `.${Tooltip.slotClassNames.content}`) + + expect(content.prop('id')).toBe(contentId) + }) + + it('uses computed "id" if "content" is passed without "id"', () => { + const wrapper = mountWithProvider( + } content="Welcome" />, + ) + const content = findIntrinsicElement(wrapper, `.${Tooltip.slotClassNames.content}`) + + expect(content.prop('id')).toMatch(/tooltip-content-\d+/) + }) + }) + describe('onOpenChange', () => { test('is called on hover', () => { const onOpenChange = jest.fn() - mountWithProvider(} content="Hi" onOpenChange={onOpenChange} />) + mountWithProvider(} content="Hi" onOpenChange={onOpenChange} />) .find('button') .simulate('mouseEnter') @@ -25,7 +48,7 @@ describe('Tooltip', () => { const onOpenChange = jest.fn() mountWithProvider( - } content="Hi" onOpenChange={onOpenChange} />, + } content="Hi" onOpenChange={onOpenChange} />, ) .find('button') .simulate('mouseEnter')