diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index bc45675509ee4..c567689be47df 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -21,10 +21,6 @@ - `SlotFill`: Migrate to TypeScript and Convert to Functional Component ``. ([#51350](https://github.com/WordPress/gutenberg/pull/51350)). -### Internal - -- `Tooltip`, `Shortcut`: Remove unused `ui/` components from the codebase ([#54573](https://github.com/WordPress/gutenberg/pull/54573)) - ## 25.8.0 (2023-09-20) ### Enhancements diff --git a/packages/components/src/color-picker/color-copy-button.tsx b/packages/components/src/color-picker/color-copy-button.tsx index 99450b07628c2..f5d7f2978ddfc 100644 --- a/packages/components/src/color-picker/color-copy-button.tsx +++ b/packages/components/src/color-picker/color-copy-button.tsx @@ -10,7 +10,8 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { CopyButton } from './styles'; -import Tooltip from '../tooltip'; +import { Text } from '../text'; +import { Tooltip } from '../ui/tooltip'; import type { ColorCopyButtonProps } from './types'; @@ -55,11 +56,14 @@ export const ColorCopyButton = ( props: ColorCopyButtonProps ) => { return ( + { copiedColor === color.toHex() + ? __( 'Copied!' ) + : __( 'Copy' ) } + } + placement="bottom" > , + forwardedRef: ForwardedRef< any > +): JSX.Element | null { + const { + as: asProp = 'span', + shortcut, + className, + ...otherProps + } = useContextSystem( props, 'Shortcut' ); + if ( ! shortcut ) { + return null; + } + + let displayText: string; + let ariaLabel: string | undefined; + + if ( typeof shortcut === 'string' ) { + displayText = shortcut; + } else { + displayText = shortcut.display; + ariaLabel = shortcut.ariaLabel; + } + + return ( + + { displayText } + + ); +} + +const ConnectedShortcut = contextConnect( Shortcut, 'Shortcut' ); + +export default ConnectedShortcut; diff --git a/packages/components/src/ui/shortcut/index.ts b/packages/components/src/ui/shortcut/index.ts new file mode 100644 index 0000000000000..6b107e956edb2 --- /dev/null +++ b/packages/components/src/ui/shortcut/index.ts @@ -0,0 +1,2 @@ +export { default as Shortcut } from './component'; +export type { Props as ShortcutProps, ShortcutDescription } from './component'; diff --git a/packages/components/src/ui/shortcut/test/__snapshots__/index.js.snap b/packages/components/src/ui/shortcut/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..edd4ed91bc352 --- /dev/null +++ b/packages/components/src/ui/shortcut/test/__snapshots__/index.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Shortcut should render a span with the shortcut text 1`] = ` + + meta + P + +`; + +exports[`Shortcut should render null when no shortcut is provided 1`] = `
`; diff --git a/packages/components/src/ui/shortcut/test/index.js b/packages/components/src/ui/shortcut/test/index.js new file mode 100644 index 0000000000000..7dc49f7b3f663 --- /dev/null +++ b/packages/components/src/ui/shortcut/test/index.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { Shortcut } from '..'; + +describe( 'Shortcut', () => { + it( 'should render null when no shortcut is provided', () => { + const { container } = render( ); + expect( container ).toMatchSnapshot(); + } ); + + it( 'should render a span with the shortcut text', () => { + const shortcutText = 'meta + P'; + render( ); + const shortcut = screen.getByText( shortcutText ); + expect( shortcut ).toMatchSnapshot(); + } ); + + it( 'should render a span with aria label', () => { + const shortcutObject = { + display: 'meta + P', + ariaLabel: 'print', + }; + render( ); + const shortcut = screen.getByText( shortcutObject.display ); + expect( shortcut ).toHaveAttribute( + 'aria-label', + shortcutObject.ariaLabel + ); + } ); +} ); diff --git a/packages/components/src/ui/tooltip/README.md b/packages/components/src/ui/tooltip/README.md new file mode 100644 index 0000000000000..424da79828a48 --- /dev/null +++ b/packages/components/src/ui/tooltip/README.md @@ -0,0 +1,21 @@ +# Tooltip + +
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ +`Tooltip` is a component to render floating help text relative to a node when it receives focus or when the user places the mouse cursor atop it. + +## Usage + +```jsx +import { Tooltip, Text } from '@wordpress/components/ui'; + +function Example() { + return ( + + WordPress.org + + ); +} +``` diff --git a/packages/components/src/ui/tooltip/component.js b/packages/components/src/ui/tooltip/component.js new file mode 100644 index 0000000000000..33106379140ff --- /dev/null +++ b/packages/components/src/ui/tooltip/component.js @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import { TooltipReference, useTooltipState } from 'reakit'; + +/** + * WordPress dependencies + */ +import { useMemo, cloneElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { contextConnect, useContextSystem } from '../context'; +import { TooltipContext } from './context'; +import TooltipContent from './content'; +import { TooltipShortcut } from './styles'; + +/** + * @param {import('../context').WordPressComponentProps} props + * @param {import('react').ForwardedRef} forwardedRef + */ +function Tooltip( props, forwardedRef ) { + const { + animated = true, + animationDuration = 160, + baseId, + children, + content, + focusable = true, + gutter = 4, + id, + modal = true, + placement, + visible = false, + shortcut, + ...otherProps + } = useContextSystem( props, 'Tooltip' ); + + const tooltip = useTooltipState( { + animated: animated ? animationDuration : undefined, + baseId: baseId || id, + gutter, + placement, + visible, + ...otherProps, + } ); + + const contextProps = useMemo( + () => ( { + tooltip, + } ), + [ tooltip ] + ); + + return ( + + { content && ( + + { content } + { shortcut && } + + ) } + { children && ( + + { ( referenceProps ) => { + if ( ! focusable ) { + referenceProps.tabIndex = undefined; + } + return cloneElement( children, referenceProps ); + } } + + ) } + + ); +} + +/** + * `Tooltip` is a component that provides context for a user interface element. + * + * @example + * ```jsx + * import { Tooltip, Text } from `@wordpress/components/ui`; + * + * function Example() { + * return ( + * + * WordPress.org + * + * ) + * } + * ``` + */ +const ConnectedTooltip = contextConnect( Tooltip, 'Tooltip' ); + +export default ConnectedTooltip; diff --git a/packages/components/src/ui/tooltip/content.js b/packages/components/src/ui/tooltip/content.js new file mode 100644 index 0000000000000..7bbf21b44aea8 --- /dev/null +++ b/packages/components/src/ui/tooltip/content.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import { Tooltip as ReakitTooltip } from 'reakit'; + +/** + * Internal dependencies + */ +import { contextConnect, useContextSystem } from '../context'; +import { View } from '../../view'; +import { useTooltipContext } from './context'; +import * as styles from './styles'; +import { useCx } from '../../utils/hooks/use-cx'; + +const { TooltipPopoverView } = styles; + +/** + * + * @param {import('../context').WordPressComponentProps} props + * @param {import('react').ForwardedRef} forwardedRef + */ +function TooltipContent( props, forwardedRef ) { + const { children, className, ...otherProps } = useContextSystem( + props, + 'TooltipContent' + ); + const { tooltip } = useTooltipContext(); + const cx = useCx(); + const classes = cx( styles.TooltipContent, className ); + + return ( + + { children } + + ); +} + +export default contextConnect( TooltipContent, 'TooltipContent' ); diff --git a/packages/components/src/ui/tooltip/context.js b/packages/components/src/ui/tooltip/context.js new file mode 100644 index 0000000000000..de7f81f9cdeb0 --- /dev/null +++ b/packages/components/src/ui/tooltip/context.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +/** + * @type {import('react').Context<{ tooltip?: import('reakit').TooltipState }>} + */ +export const TooltipContext = createContext( {} ); +export const useTooltipContext = () => useContext( TooltipContext ); diff --git a/packages/components/src/ui/tooltip/index.js b/packages/components/src/ui/tooltip/index.js new file mode 100644 index 0000000000000..96841fe7ff4ed --- /dev/null +++ b/packages/components/src/ui/tooltip/index.js @@ -0,0 +1,2 @@ +export { default as Tooltip } from './component'; +export * from './context'; diff --git a/packages/components/src/ui/tooltip/stories/index.story.js b/packages/components/src/ui/tooltip/stories/index.story.js new file mode 100644 index 0000000000000..a0032da0d0ab9 --- /dev/null +++ b/packages/components/src/ui/tooltip/stories/index.story.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { Text } from '../../../text'; +import { Tooltip } from '../index'; + +export default { + component: Tooltip, + title: 'Components (Experimental)/Tooltip', +}; + +export const _default = () => { + return ( + + Hello + + ); +}; diff --git a/packages/components/src/ui/tooltip/styles.js b/packages/components/src/ui/tooltip/styles.js new file mode 100644 index 0000000000000..fca4875a9d1c1 --- /dev/null +++ b/packages/components/src/ui/tooltip/styles.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +/** + * Internal dependencies + */ +import { Shortcut } from '../shortcut'; +import * as ZIndex from '../../utils/z-index'; +import CONFIG from '../../utils/config-values'; +import { space } from '../utils/space'; +import { COLORS } from '../../utils/colors-values'; + +export const TooltipContent = css` + z-index: ${ ZIndex.Tooltip }; + box-sizing: border-box; + opacity: 0; + outline: none; + transform-origin: top center; + transition: opacity ${ CONFIG.transitionDurationFastest } ease; + font-size: ${ CONFIG.fontSize }; + + &[data-enter] { + opacity: 1; + } +`; + +export const TooltipPopoverView = styled.div` + background: rgba( 0, 0, 0, 0.8 ); + border-radius: 2px; + box-shadow: 0 0 0 1px rgba( 255, 255, 255, 0.04 ); + color: ${ COLORS.white }; + padding: 4px 8px; +`; + +export const noOutline = css` + outline: none; +`; + +export const TooltipShortcut = styled( Shortcut )` + display: inline-block; + margin-left: ${ space( 1 ) }; +`; diff --git a/packages/components/src/ui/tooltip/test/__snapshots__/index.js.snap b/packages/components/src/ui/tooltip/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..60edeba1681a2 --- /dev/null +++ b/packages/components/src/ui/tooltip/test/__snapshots__/index.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`props should render correctly 1`] = ` +.emotion-0 { + z-index: 1000002; + box-sizing: border-box; + opacity: 0; + outline: none; + transform-origin: top center; + -webkit-transition: opacity 100ms ease; + transition: opacity 100ms ease; + font-size: 13px; +} + +.emotion-0[data-enter] { + opacity: 1; +} + +.emotion-2 { + background: rgba( 0, 0, 0, 0.8 ); + border-radius: 2px; + box-shadow: 0 0 0 1px rgba( 255, 255, 255, 0.04 ); + color: #fff; + padding: 4px 8px; +} + + +`; diff --git a/packages/components/src/ui/tooltip/test/index.js b/packages/components/src/ui/tooltip/test/index.js new file mode 100644 index 0000000000000..b95a866738bd2 --- /dev/null +++ b/packages/components/src/ui/tooltip/test/index.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { Text } from '../../../text'; +import { Tooltip } from '../index'; + +describe( 'props', () => { + const baseTooltipId = 'base-tooltip'; + const baseTooltipTriggerContent = 'WordPress.org - Base trigger content'; + const byId = ( id ) => ( t ) => t.id === id; + const VisibleTooltip = () => ( + + { baseTooltipTriggerContent } + + ); + + test( 'should render correctly', () => { + render( ); + const tooltip = screen.getByRole( 'tooltip' ); + expect( tooltip ).toMatchSnapshot(); + } ); + + test( 'should render invisible', () => { + render( ); + const invisibleTooltipTriggerContent = 'WordPress.org - Invisible'; + render( + + { invisibleTooltipTriggerContent } + + ); + const tooltip = screen.getByRole( 'tooltip' ); + const invisibleTooltipTrigger = screen.getByText( + invisibleTooltipTriggerContent + ); + // The base tooltip should render only; invisible tooltip should not render. + expect( tooltip ).toBeInTheDocument(); + // Assert that the rendered tooltip is indeed the base tooltip. + expect( tooltip.id ).toBe( baseTooltipId ); + // But the invisible tooltip's trigger still should have rendered. + expect( invisibleTooltipTrigger ).not.toBeUndefined(); + } ); + + test( 'should render without children', () => { + render( ); + const childlessTooltipId = 'tooltip-without-children'; + render( + + ); + const tooltips = screen.getAllByRole( 'tooltip' ); + const childlessTooltip = tooltips.find( byId( childlessTooltipId ) ); + expect( childlessTooltip ).not.toBeUndefined(); + } ); + + test( 'should not render a tooltip without content', () => { + render( ); + const contentlessTooltipId = 'contentless-tooltip'; + render( + + WordPress.org + + ); + const tooltip = screen.getByRole( 'tooltip' ); + // Assert only the base tooltip rendered. + expect( tooltip ).toBeInTheDocument(); + expect( tooltip.id ).toBe( baseTooltipId ); + } ); +} ); diff --git a/packages/components/src/ui/tooltip/types.ts b/packages/components/src/ui/tooltip/types.ts new file mode 100644 index 0000000000000..7c50dca3a2937 --- /dev/null +++ b/packages/components/src/ui/tooltip/types.ts @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import type { TooltipInitialState, TooltipProps } from 'reakit'; +import type { ReactElement, ReactNode } from 'react'; + +/** + * Internal dependencies + */ +import type { PopperProps } from '../utils/types'; +import type { ShortcutProps } from '../shortcut'; + +export type Props = TooltipInitialState & + PopperProps & + Pick< ShortcutProps, 'shortcut' > & { + /** + * Determines if `Tooltip` has animations. + */ + animated?: boolean; + /** + * The duration of `Tooltip` animations. + * + * @default 160 + */ + animationDuration?: boolean; + /** + * ID that will serve as a base for all the items IDs. + * + * @see https://reakit.io/docs/tooltip/#usetooltipstate + */ + baseId?: string; + /** + * Content to render within the `Tooltip` floating label. + */ + content?: ReactElement; + /** + * Spacing between the `Tooltip` reference and the floating label. + * + * @default 4 + * + * @see https://reakit.io/docs/tooltip/#usetooltipstate + */ + gutter?: number; + /** + * Whether or not the dialog should be rendered within `Portal`. It's true by default if modal is true. + * + * @default true + * + * @see https://reakit.io/docs/tooltip/#tooltip + */ + modal?: boolean; + /** + * Whether `Tooltip` is visible. + * + * @default false + * + * @see https://reakit.io/docs/tooltip/#usetooltipstate + */ + visible?: boolean; + children: ReactElement; + focusable?: boolean; + }; + +export type ContentProps = TooltipProps & { + children: ReactNode; +}; diff --git a/packages/components/src/ui/utils/types.ts b/packages/components/src/ui/utils/types.ts index d0ea62501a1f6..538413204f781 100644 --- a/packages/components/src/ui/utils/types.ts +++ b/packages/components/src/ui/utils/types.ts @@ -1 +1,36 @@ export type ResponsiveCSSValue< T > = Array< T | undefined > | T; + +export type PopperPlacement = + | 'auto' + | 'auto-start' + | 'auto-end' + | 'top' + | 'top-start' + | 'top-end' + | 'right' + | 'right-start' + | 'right-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end'; + +export type PopperProps = { + /** + * Position of the popover element. + * + * @default 'auto' + * + * @see https://popper.js.org/docs/v1/#popperplacements--codeenumcode + */ + placement?: PopperPlacement; +}; + +export type SizeRangeDefault = + | 'xLarge' + | 'large' + | 'medium' + | 'small' + | 'xSmall'; diff --git a/packages/components/src/utils/types.ts b/packages/components/src/utils/types.ts new file mode 100644 index 0000000000000..54fe75edaa306 --- /dev/null +++ b/packages/components/src/utils/types.ts @@ -0,0 +1,36 @@ +export type SizeRangeDefault = + | 'xLarge' + | 'large' + | 'medium' + | 'small' + | 'xSmall'; + +export type SizeRangeReduced = 'large' | 'medium' | 'small'; + +export type PopperPlacement = + | 'auto' + | 'auto-start' + | 'auto-end' + | 'top' + | 'top-start' + | 'top-end' + | 'right' + | 'right-start' + | 'right-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end'; + +export type PopperProps = { + /** + * Position of the popover element. + * + * @default 'auto' + * + * @see https://popper.js.org/docs/v1/#popperplacements--codeenumcode + */ + placement?: PopperPlacement; +}; diff --git a/packages/components/src/utils/z-index.js b/packages/components/src/utils/z-index.js new file mode 100644 index 0000000000000..600f51c673aac --- /dev/null +++ b/packages/components/src/utils/z-index.js @@ -0,0 +1,2 @@ +export const Flyout = 10000; +export const Tooltip = 1000002;