From e642ff7dd4b9b18e7f1bee024663bf41271ba8c2 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 24 May 2022 15:01:41 +0200 Subject: [PATCH] Revert "Revert accessible Tooltip + IconButton (#2092)" This reverts commit c67568bdc5ae411f33f5bfa903aad873f9c1da77. --- .changeset/iconbutton-default-tooltip.md | 5 + .changeset/improved-tooltip.md | 6 + docs/content/IconButton.mdx | 10 + docs/content/Tooltip.md | 34 - docs/content/Tooltip.mdx | 194 ++++++ package-lock.json | 14 +- package.json | 2 +- src/Button/Button.stories.tsx | 32 +- src/Button/IconButton.tsx | 41 +- src/Button/types.ts | 2 + src/Tooltip.tsx | 263 -------- src/Tooltip/index.tsx | 180 +++++ src/_TextInputInnerAction.tsx | 2 +- src/__tests__/ActionMenu.test.tsx | 29 +- src/__tests__/Button.test.tsx | 14 +- src/__tests__/TextInput.test.tsx | 50 +- src/__tests__/Tooltip.test.tsx | 63 +- src/__tests__/Tooltip.types.test.tsx | 5 +- .../__snapshots__/Button.test.tsx.snap | 2 +- .../__snapshots__/TextInput.test.tsx.snap | 634 ++++++------------ .../__snapshots__/Tooltip.test.tsx.snap | 262 ++------ .../hooks/useAnchoredPosition.test.tsx | 1 + src/index.ts | 2 +- src/stories/ActionMenu/fixtures.stories.tsx | 1 + src/stories/Tooltip.stories.tsx | 37 - src/stories/Tooltip/examples.stories.tsx | 61 ++ src/stories/Tooltip/fixtures.stories.tsx | 108 +++ .../behaviors-anchored-position.stories.tsx | 345 ++++++++++ 28 files changed, 1347 insertions(+), 1052 deletions(-) create mode 100644 .changeset/iconbutton-default-tooltip.md create mode 100644 .changeset/improved-tooltip.md delete mode 100644 docs/content/Tooltip.md create mode 100644 docs/content/Tooltip.mdx delete mode 100644 src/Tooltip.tsx create mode 100644 src/Tooltip/index.tsx delete mode 100644 src/stories/Tooltip.stories.tsx create mode 100644 src/stories/Tooltip/examples.stories.tsx create mode 100644 src/stories/Tooltip/fixtures.stories.tsx create mode 100644 src/stories/behaviors-anchored-position.stories.tsx diff --git a/.changeset/iconbutton-default-tooltip.md b/.changeset/iconbutton-default-tooltip.md new file mode 100644 index 00000000000..7fb2b407016 --- /dev/null +++ b/.changeset/iconbutton-default-tooltip.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +[IconButton](https://primer.style/react/IconButton) now has a tooltip by default, it can be [customised by wrapping in a Tooltip](https://primer.style/react/IconButton#customize-description--tooltip-text) ([#2006](https://github.com/primer/react/pull/2006)) diff --git a/.changeset/improved-tooltip.md b/.changeset/improved-tooltip.md new file mode 100644 index 00000000000..acaea20e976 --- /dev/null +++ b/.changeset/improved-tooltip.md @@ -0,0 +1,6 @@ +--- +'@primer/react': patch +--- + +Accessibility and position fixes (backward compatible) for Tooltip ([#2006](https://github.com/primer/react/pull/2006)) + diff --git a/docs/content/IconButton.mdx b/docs/content/IconButton.mdx index 2af6b14024b..0eceefea19b 100644 --- a/docs/content/IconButton.mdx +++ b/docs/content/IconButton.mdx @@ -35,6 +35,16 @@ A separate component called `IconButton` is used if the action shows only an ico ``` +### Customize description / tooltip text + +To add description for the button, wrap `IconButton` in a `Tooltip`. Make sure you pass `aria-label` to the button as well. + +```jsx live + + + +``` + ## API reference Native ` + +``` + +### With direction + +Set direction of tooltip with `direction`. The tooltip is responsive and will automatically adjust direction to avoid cutting off. + +```jsx live + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## Props + +### Tooltip + + + + + + + Use text instead + + } + /> + + Use aria-describedby or aria-labelledby + + } + /> + + + + When set to true, tooltip appears without any delay + + } + /> + + Use to allow text within tooltip to wrap. Deprecated: always set to true now. + + } + /> + + + +## Status + + + +## Further reading + +- [Tooltip alternatives](https://primer.style/design/accessibility/tooltip-alternatives) + +## Related components + +- [IconButton](/IconButton) diff --git a/package-lock.json b/package-lock.json index cb5ec85d317..ceb0be69e41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "35.2.1", "license": "MIT", "dependencies": { - "@primer/behaviors": "1.1.1", + "@primer/behaviors": "^1.1.3", "@primer/octicons-react": "16.1.1", "@primer/primitives": "7.6.0", "@radix-ui/react-polymorphic": "0.0.14", @@ -5588,9 +5588,9 @@ } }, "node_modules/@primer/behaviors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.1.1.tgz", - "integrity": "sha512-wvF1PYjyxKNTr6+5w4uR5Gkz53t1fsRDgKjWxDKk7wmlh0cwiILBo4dDFjjVhWRF1mBSjaIxxJGB4WGaP7ct2Q==" + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.1.3.tgz", + "integrity": "sha512-WpCcjAkXG7Lv3ZbaCUgASWKHnCi/pmuSEiyTmHHb6f5xhwk1mliixNL5ZZHtDN6RCcT3VnXUsyek4GopG2lbZQ==" }, "node_modules/@primer/octicons-react": { "version": "16.1.1", @@ -38627,9 +38627,9 @@ "dev": true }, "@primer/behaviors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.1.1.tgz", - "integrity": "sha512-wvF1PYjyxKNTr6+5w4uR5Gkz53t1fsRDgKjWxDKk7wmlh0cwiILBo4dDFjjVhWRF1mBSjaIxxJGB4WGaP7ct2Q==" + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.1.3.tgz", + "integrity": "sha512-WpCcjAkXG7Lv3ZbaCUgASWKHnCi/pmuSEiyTmHHb6f5xhwk1mliixNL5ZZHtDN6RCcT3VnXUsyek4GopG2lbZQ==" }, "@primer/octicons-react": { "version": "16.1.1", diff --git a/package.json b/package.json index c847db92cd2..45b1490df10 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "npm": ">=7" }, "dependencies": { - "@primer/behaviors": "1.1.1", + "@primer/behaviors": "^1.1.3", "@primer/octicons-react": "16.1.1", "@primer/primitives": "7.6.0", "@radix-ui/react-polymorphic": "0.0.14", diff --git a/src/Button/Button.stories.tsx b/src/Button/Button.stories.tsx index 800915dc3a9..0f55c557a84 100644 --- a/src/Button/Button.stories.tsx +++ b/src/Button/Button.stories.tsx @@ -1,9 +1,10 @@ -import {EyeClosedIcon, EyeIcon, SearchIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react' +import {BellIcon, EyeClosedIcon, EyeIcon, SearchIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react' import {Meta} from '@storybook/react' import React, {useState} from 'react' import {Button, ButtonProps, IconButton} from '.' import {BaseStyles, ThemeProvider} from '..' import Box from '../Box' +import {Tooltip} from '../Tooltip' export default { title: 'Composite components/Button', @@ -93,6 +94,35 @@ export const iconButton = ({...args}: ButtonProps) => { ) } +export const iconButtonWithTooltip = ({...args}: ButtonProps) => { + return ( + <> + + Default tooltip + + + + Custom tooltip text + + + + + + Custom tooltip direction + + + + + + Disable tooltip + + + + + + ) +} + export const WatchCounterButton = ({...args}: ButtonProps) => { const [count, setCount] = useState(0) return ( diff --git a/src/Button/IconButton.tsx b/src/Button/IconButton.tsx index 401650829fc..e4e13a4758d 100644 --- a/src/Button/IconButton.tsx +++ b/src/Button/IconButton.tsx @@ -4,22 +4,51 @@ import {useTheme} from '../ThemeProvider' import Box from '../Box' import {IconButtonProps, StyledButton} from './types' import {getBaseStyles, getSizeStyles, getVariantStyles} from './styles' +import {Tooltip, TooltipContext} from '../Tooltip' const IconButton = forwardRef((props, forwardedRef): JSX.Element => { - const {variant = 'default', size = 'medium', sx: sxProp = {}, icon: Icon, ...rest} = props + const { + variant = 'default', + size = 'medium', + sx: sxProp = {}, + icon: Icon, + disableTooltip = false, + 'aria-label': ariaLabel, + ...rest + } = props const {theme} = useTheme() + const sxStyles = merge.all([ getBaseStyles(theme), getSizeStyles(size, variant, true), getVariantStyles(variant, theme), sxProp as SxProp ]) + + // If button is already wrapped in a Tooltip, + // do not add another. + const {tooltipId} = React.useContext(TooltipContext) + + if (tooltipId || disableTooltip) { + return ( + + + + + + ) + } + + // use Tooltip with type=label and skip aria-label on button + // because the aria-labelledby is provided by the tooltip return ( - - - - - + + + + + + + ) }) diff --git a/src/Button/types.ts b/src/Button/types.ts index 7568bd3910d..147e1f7c812 100644 --- a/src/Button/types.ts +++ b/src/Button/types.ts @@ -48,6 +48,8 @@ export type ButtonProps = { export type IconButtonProps = ButtonA11yProps & { icon: React.FunctionComponent + 'aria-label': string + disableTooltip?: boolean } & ButtonBaseProps // adopted from React.AnchorHTMLAttributes diff --git a/src/Tooltip.tsx b/src/Tooltip.tsx deleted file mode 100644 index 0e3baa22b2d..00000000000 --- a/src/Tooltip.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import classnames from 'classnames' -import React from 'react' -import styled from 'styled-components' -import {get} from './constants' -import sx, {SxProp} from './sx' -import {ComponentProps} from './utils/types' - -const TooltipBase = styled.span` - position: relative; - - &::before { - position: absolute; - z-index: 1000001; - display: none; - width: 0px; - height: 0px; - color: ${get('colors.neutral.emphasisPlus')}; - pointer-events: none; - content: ''; - border: 6px solid transparent; - opacity: 0; - } - - &::after { - position: absolute; - z-index: 1000000; - display: none; - padding: 0.5em 0.75em; - font: normal normal 11px/1.5 ${get('fonts.normal')}; - -webkit-font-smoothing: subpixel-antialiased; - color: ${get('colors.fg.onEmphasis')}; - text-align: center; - text-decoration: none; - text-shadow: none; - text-transform: none; - letter-spacing: normal; - word-wrap: break-word; - white-space: pre; - pointer-events: none; - content: attr(aria-label); - background: ${get('colors.neutral.emphasisPlus')}; - border-radius: ${get('radii.1')}; - opacity: 0; - } - - // delay animation for tooltip - @keyframes tooltip-appear { - from { - opacity: 0; - } - - to { - opacity: 1; - } - } - - &:hover, - &:active, - &:focus { - &::before, - &::after { - display: inline-block; - text-decoration: none; - animation-name: tooltip-appear; - animation-duration: 0.1s; - animation-fill-mode: forwards; - animation-timing-function: ease-in; - animation-delay: 0.4s; - } - } - - &.tooltipped-no-delay:hover, - &.tooltipped-no-delay:active, - &.tooltipped-no-delay:focus { - &::before, - &::after { - animation-delay: 0s; - } - } - - &.tooltipped-multiline:hover, - &.tooltipped-multiline:active, - &.tooltipped-multiline:focus { - &::after { - display: table-cell; - } - } - - // Tooltipped south - &.tooltipped-s, - &.tooltipped-se, - &.tooltipped-sw { - &::after { - top: 100%; - right: 50%; - margin-top: 6px; - } - - &::before { - top: auto; - right: 50%; - bottom: -7px; - margin-right: -6px; - border-bottom-color: ${get('colors.neutral.emphasisPlus')}; - } - } - - &.tooltipped-se { - &::after { - right: auto; - left: 50%; - margin-left: -${get('space.3')}; - } - } - - &.tooltipped-sw::after { - margin-right: -${get('space.3')}; - } - - // Tooltips above the object - &.tooltipped-n, - &.tooltipped-ne, - &.tooltipped-nw { - &::after { - right: 50%; - bottom: 100%; - margin-bottom: 6px; - } - - &::before { - top: -7px; - right: 50%; - bottom: auto; - margin-right: -6px; - border-top-color: ${get('colors.neutral.emphasisPlus')}; - } - } - - &.tooltipped-ne { - &::after { - right: auto; - left: 50%; - margin-left: -${get('space.3')}; - } - } - - &.tooltipped-nw::after { - margin-right: -${get('space.3')}; - } - - // Move the tooltip body to the center of the object. - &.tooltipped-s::after, - &.tooltipped-n::after { - transform: translateX(50%); - } - - // Tooltipped to the left - &.tooltipped-w { - &::after { - right: 100%; - bottom: 50%; - margin-right: 6px; - transform: translateY(50%); - } - - &::before { - top: 50%; - bottom: 50%; - left: -7px; - margin-top: -6px; - border-left-color: ${get('colors.neutral.emphasisPlus')}; - } - } - - // tooltipped to the right - &.tooltipped-e { - &::after { - bottom: 50%; - left: 100%; - margin-left: 6px; - transform: translateY(50%); - } - - &::before { - top: 50%; - right: -7px; - bottom: 50%; - margin-top: -6px; - border-right-color: ${get('colors.neutral.emphasisPlus')}; - } - } - - &.tooltipped-multiline { - &::after { - width: max-content; - max-width: 250px; - word-wrap: break-word; - white-space: pre-line; - border-collapse: separate; - } - - &.tooltipped-s::after, - &.tooltipped-n::after { - right: auto; - left: 50%; - transform: translateX(-50%); - } - - &.tooltipped-w::after, - &.tooltipped-e::after { - right: 100%; - } - } - - &.tooltipped-align-right-2::after { - right: 0; - margin-right: 0; - } - - &.tooltipped-align-right-2::before { - right: 15px; - } - - &.tooltipped-align-left-2::after { - left: 0; - margin-left: 0; - } - - &.tooltipped-align-left-2::before { - left: 10px; - } - - ${sx}; -` - -export type TooltipProps = { - direction?: 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw' - text?: string - noDelay?: boolean - align?: 'left' | 'right' - wrap?: boolean -} & ComponentProps - -function Tooltip({direction = 'n', children, className, text, noDelay, align, wrap, ...rest}: TooltipProps) { - const classes = classnames( - className, - `tooltipped-${direction}`, - align && `tooltipped-align-${align}-2`, - noDelay && 'tooltipped-no-delay', - wrap && 'tooltipped-multiline' - ) - return ( - - {children} - - ) -} - -Tooltip.alignments = ['left', 'right'] - -Tooltip.directions = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] - -export default Tooltip diff --git a/src/Tooltip/index.tsx b/src/Tooltip/index.tsx new file mode 100644 index 00000000000..bd92e1317aa --- /dev/null +++ b/src/Tooltip/index.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import {useSSRSafeId} from '@react-aria/ssr' +import type {AnchorPosition, AnchorSide, AnchorAlignment} from '@primer/behaviors' +import Box from '../Box' +import {useAnchoredPosition, useProvidedRefOrCreate} from '../hooks' +import {SxProp, merge, BetterSystemStyleObject} from '../sx' + +type TooltipDirection = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' +type TooltipAlign = 'left' | 'right' + +export type TooltipProps = { + /** The text content of the tooltip. This should be brief and no longer than a sentence. + * Marked as optional to support backward compatibility with aria-label. */ + text?: string + /** @deprecated Use `text` instead */ + 'aria-label'?: string + /** Direction relative to target */ + direction?: TooltipDirection + /** @deprecated Use `direction` instead. Alignment relative to target. */ + align?: TooltipAlign + /** Use aria-describedby or aria-labelledby */ + type?: 'description' | 'label' + /** Tooltip target */ + children: React.ReactElement & {ref?: React.RefObject} + /** When set to true, tooltip appears without any delay */ + noDelay?: boolean + /** @deprecated Always set to true now. */ + wrap?: boolean +} & SxProp + +// map tooltip direction to anchoredPosition props +const directionToPosition: Record = { + nw: {side: 'outside-top', align: 'start'}, + n: {side: 'outside-top', align: 'center'}, + ne: {side: 'outside-top', align: 'end'}, + e: {side: 'outside-right', align: 'center'}, + se: {side: 'outside-bottom', align: 'end'}, + s: {side: 'outside-bottom', align: 'center'}, + sw: {side: 'outside-bottom', align: 'start'}, + w: {side: 'outside-left', align: 'center'} +} + +// map align to AnchorAlignment +const alignToAnchorAlignment: Record = {left: 'start', right: 'end'} + +export const TooltipContext = React.createContext<{tooltipId?: string}>({}) + +export const Tooltip: React.FC = ({ + text, + children, + direction = 'n', + align, + type = 'description', + noDelay = false, + sx = {}, + ...props +}) => { + const tooltipId = useSSRSafeId() + + const childRef = children.ref + const anchorElementRef = useProvidedRefOrCreate(childRef) + const tooltipRef = React.useRef(null) + + const child = React.cloneElement(children, { + ref: anchorElementRef, + [type === 'description' ? 'aria-describedby' : 'aria-labelledby']: tooltipId + }) + + const {position} = useAnchoredPosition({ + side: directionToPosition[direction].side, + // support both algin and direction for backward compatibility + align: align ? alignToAnchorAlignment[align] : directionToPosition[direction].align, + floatingElementRef: tooltipRef, + anchorElementRef + }) + + const tooltipText = text || props['aria-label'] + + return ( + + {child} + + + ) +} + +const FloatingTooltip = React.forwardRef< + HTMLDivElement, + Pick & {id: string; position?: AnchorPosition} +>(({id, text, noDelay, position, sx = {}}, ref) => { + const styles: BetterSystemStyleObject = { + visibility: 'hidden', + opacity: 0, + transition: 'opacity 100ms ease-in', + transitionDelay: noDelay ? '0ms' : '400ms', + + backgroundColor: 'neutral.emphasisPlus', + color: 'fg.onEmphasis', + borderRadius: 1, + fontSize: 0, + paddingY: 1, + paddingX: 2, + width: 'fit-content', + maxWidth: '250px', + textAlign: 'center', + position: 'absolute', + zIndex: 2, + top: position?.top, + left: position?.left, + + ':before': { + content: '""', + width: 0, + height: 0, + border: '5px solid transparent', + position: 'absolute' + }, + + '&[data-side=outside-top]::before': { + borderTop: '5px solid', + borderTopColor: 'neutral.emphasisPlus', + top: '100%' + }, + '&[data-side=outside-bottom]::before': { + borderBottom: '5px solid', + borderBottomColor: 'neutral.emphasisPlus', + top: '-10px' + }, + '&[data-side=outside-left]::before': { + borderLeft: '5px solid', + borderLeftColor: 'neutral.emphasisPlus', + top: 'calc(50% - 5px)', + left: '100%' + }, + '&[data-side=outside-right]::before': { + borderRight: '5px solid', + borderRightColor: 'neutral.emphasisPlus', + top: 'calc(50% - 5px)', + left: '-10px' + }, + + '&[data-align=start][data-side=outside-top]::before, &[data-align=start][data-side=outside-bottom]::before': { + left: '8px' + }, + '&[data-align=center][data-side=outside-top]::before, &[data-align=center][data-side=outside-bottom]::before': { + left: 'calc(50% - 4px)' + }, + '&[data-align=end][data-side=outside-top]::before, &[data-align=end][data-side=outside-bottom]::before': { + left: 'calc(100% - 16px)' + } + } + + return ( + (styles, sx)} + > + {text} + + ) +}) diff --git a/src/_TextInputInnerAction.tsx b/src/_TextInputInnerAction.tsx index 255f8c842f6..d33d63f0f0b 100644 --- a/src/_TextInputInnerAction.tsx +++ b/src/_TextInputInnerAction.tsx @@ -41,7 +41,7 @@ const invisibleButtonStyleOverrides = { const ConditionalTooltip: React.FC<{ ['aria-label']?: string - children: React.ReactNode + children: React.ReactElement }> = ({'aria-label': ariaLabel, children}) => ( <> {ariaLabel ? ( diff --git a/src/__tests__/ActionMenu.test.tsx b/src/__tests__/ActionMenu.test.tsx index 7aa30c560ec..29fee6e3066 100644 --- a/src/__tests__/ActionMenu.test.tsx +++ b/src/__tests__/ActionMenu.test.tsx @@ -3,10 +3,11 @@ import 'babel-polyfill' import {axe, toHaveNoViolations} from 'jest-axe' import React from 'react' import theme from '../theme' -import {ActionMenu, ActionList, BaseStyles, ThemeProvider, SSRProvider} from '..' +import {ActionMenu, ActionList, BaseStyles, ThemeProvider, SSRProvider, IconButton} from '..' import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' import {SingleSelection, MixedSelection} from '../stories/ActionMenu/examples.stories' import '@testing-library/jest-dom' +import {TriangleDownIcon} from '@primer/octicons-react' expect.extend(toHaveNoViolations) function Example(): JSX.Element { @@ -136,6 +137,32 @@ describe('ActionMenu', () => { cleanup() }) + it('should open Menu on MenuAnchor click with IconButton', async () => { + const component = HTMLRender( + + + + + + + + + + New file + Copy link + + + + + + + ) + const button = component.getByLabelText('Toggle Menu') + fireEvent.click(button) + expect(component.getByRole('menu')).toBeInTheDocument() + cleanup() + }) + it('should have no axe violations', async () => { const {container} = HTMLRender() const results = await axe(container) diff --git a/src/__tests__/Button.test.tsx b/src/__tests__/Button.test.tsx index 7e917445f9e..fc8d0ca69dc 100644 --- a/src/__tests__/Button.test.tsx +++ b/src/__tests__/Button.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {IconButton, Button} from '../Button' +import {SSRProvider, IconButton, Button} from '../' import {behavesAsComponent} from '../utils/testing' import {render, cleanup, fireEvent} from '@testing-library/react' import {axe, toHaveNoViolations} from 'jest-axe' @@ -91,13 +91,21 @@ describe('Button', () => { }) it('styles icon only button to make it a square', () => { - const container = render() + const container = render( + + + + ) const IconOnlyButton = container.getByRole('button') expect(IconOnlyButton).toHaveStyleRule('padding-right', '8px') expect(IconOnlyButton).toMatchSnapshot() }) it('makes sure icon button has an aria-label', () => { - const container = render() + const container = render( + + + + ) const IconOnlyButton = container.getByLabelText('Search button') expect(IconOnlyButton).toBeTruthy() }) diff --git a/src/__tests__/TextInput.test.tsx b/src/__tests__/TextInput.test.tsx index 1516917ca2b..5a9fed253c5 100644 --- a/src/__tests__/TextInput.test.tsx +++ b/src/__tests__/TextInput.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {TextInput} from '..' +import {SSRProvider, TextInput} from '..' import {render, mount, behavesAsComponent, checkExports} from '../utils/testing' import {render as HTMLRender, cleanup, fireEvent} from '@testing-library/react' import {axe, toHaveNoViolations} from 'jest-axe' @@ -68,11 +68,13 @@ describe('TextInput', () => { const handleAction = jest.fn() expect( render( - Clear} - /> + + Clear} + /> + ) ).toMatchSnapshot() }) @@ -81,15 +83,17 @@ describe('TextInput', () => { const handleAction = jest.fn() expect( render( - - Clear - - } - /> + + + Clear + + } + /> + ) ).toMatchSnapshot() }) @@ -98,22 +102,24 @@ describe('TextInput', () => { const handleAction = jest.fn() expect( render( - } - /> + + } + /> + ) ).toMatchSnapshot() }) it('focuses the text input if you do not click the input element', () => { const {container, getByLabelText} = HTMLRender( - <> + {/* eslint-disable-next-line jsx-a11y/label-has-for */} - + ) const icon = container.querySelector('svg')! diff --git a/src/__tests__/Tooltip.test.tsx b/src/__tests__/Tooltip.test.tsx index 4a285eea46e..92ecbc0f8bb 100644 --- a/src/__tests__/Tooltip.test.tsx +++ b/src/__tests__/Tooltip.test.tsx @@ -1,52 +1,45 @@ import React from 'react' -import Tooltip, {TooltipProps} from '../Tooltip' -import {render, renderClasses, rendersClass, behavesAsComponent, checkExports} from '../utils/testing' +import 'babel-polyfill' +import {Tooltip, TooltipContext} from '../Tooltip' +import {SSRProvider} from '..' +import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' import {render as HTMLRender, cleanup} from '@testing-library/react' import {axe, toHaveNoViolations} from 'jest-axe' -import 'babel-polyfill' +import '@testing-library/jest-dom' expect.extend(toHaveNoViolations) -describe('Tooltip', () => { - behavesAsComponent({Component: Tooltip}) +const Fixture = () => { + return ( + + + + + + ) +} - checkExports('Tooltip', { - default: Tooltip +describe('Tooltip', () => { + behavesAsComponent({ + Component: Tooltip, + options: {skipAs: true, skipSx: true}, + toRender: () => }) + checkExports('Tooltip', {default: undefined, Tooltip, TooltipContext}) + it('should have no axe violations', async () => { - const {container} = HTMLRender() + const {container} = HTMLRender() const results = await axe(container) expect(results).toHaveNoViolations() cleanup() }) - it('renders a with the "tooltipped" class', () => { - expect(render().type).toEqual('span') - expect(renderClasses()).toContain('tooltipped-n') - }) - - it('respects the "align" prop', () => { - expect(rendersClass(, 'tooltipped-align-left-2')).toBe(true) - expect(rendersClass(, 'tooltipped-align-right-2')).toBe(true) - }) - - it('respects the "direction" prop', () => { - for (const direction of Tooltip.directions) { - expect( - rendersClass(, `tooltipped-${direction}`) - ).toBe(true) - } - }) - - it('respects the "noDelay" prop', () => { - expect(rendersClass(, 'tooltipped-no-delay')).toBe(true) - }) - - it('respects the "text" prop', () => { - expect(render().props['aria-label']).toEqual('hi') + it('tooltip should not be visible by default', () => { + const component = HTMLRender() + expect(component.getByText('tooltip text')).not.toBeVisible() + cleanup() }) - it('respects the "wrap" prop', () => { - expect(rendersClass(, 'tooltipped-multiline')).toBe(true) - }) + checkStoriesForAxeViolations('Tooltip/fixtures') + checkStoriesForAxeViolations('Tooltip/examples') }) diff --git a/src/__tests__/Tooltip.types.test.tsx b/src/__tests__/Tooltip.types.test.tsx index c9280121588..59c0e45ac54 100644 --- a/src/__tests__/Tooltip.types.test.tsx +++ b/src/__tests__/Tooltip.types.test.tsx @@ -1,7 +1,8 @@ import React from 'react' -import Tooltip from '../Tooltip' +import {Tooltip} from '../Tooltip' -export function shouldAcceptCallWithNoProps() { +export function shouldNotAcceptCallWithMissingProps() { + // @ts-expect-error props missing return } diff --git a/src/__tests__/__snapshots__/Button.test.tsx.snap b/src/__tests__/__snapshots__/Button.test.tsx.snap index f54c410ac25..bdeeeee95bf 100644 --- a/src/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/__tests__/__snapshots__/Button.test.tsx.snap @@ -324,7 +324,7 @@ exports[`Button styles icon only button to make it a square 1`] = ` } + @@ -1981,6 +1853,90 @@ exports[`TextInput renders trailingAction text button with a tooltip 1`] = ` margin: 4px; } +.c3 { + line-height: 1; +} + +.c3:hover [data-component=tooltip], +.c3:focus-within [data-component=tooltip] { + visibility: visible; + opacity: 1; +} + +.c5 { + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 100ms ease-in; + transition: opacity 100ms ease-in; + -webkit-transition-delay: 400ms; + transition-delay: 400ms; + background-color: #24292f; + color: #ffffff; + border-radius: 3px; + font-size: 12px; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 8px; + padding-right: 8px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + max-width: 250px; + text-align: center; + position: absolute; + z-index: 2; + display: inline-block; +} + +.c5:before { + content: ""; + width: 0; + height: 0; + border: 5px solid transparent; + position: absolute; +} + +.c5[data-side=outside-top]::before { + border-top: 5px solid; + border-top-color: #24292f; + top: 100%; +} + +.c5[data-side=outside-bottom]::before { + border-bottom: 5px solid; + border-bottom-color: #24292f; + top: -10px; +} + +.c5[data-side=outside-left]::before { + border-left: 5px solid; + border-left-color: #24292f; + top: calc(50% - 5px); + left: 100%; +} + +.c5[data-side=outside-right]::before { + border-right: 5px solid; + border-right-color: #24292f; + top: calc(50% - 5px); + left: -10px; +} + +.c5[data-align=start][data-side=outside-top]::before, +.c5[data-align=start][data-side=outside-bottom]::before { + left: 8px; +} + +.c5[data-align=center][data-side=outside-top]::before, +.c5[data-align=center][data-side=outside-bottom]::before { + left: calc(50% - 4px); +} + +.c5[data-align=end][data-side=outside-top]::before, +.c5[data-align=end][data-side=outside-bottom]::before { + left: calc(100% - 16px); +} + .c4 { border-radius: 6px; border: 0; @@ -2164,226 +2120,6 @@ exports[`TextInput renders trailingAction text button with a tooltip 1`] = ` outline: 0; } -.c3 { - position: relative; - display: inline-block; -} - -.c3::before { - position: absolute; - z-index: 1000001; - display: none; - width: 0px; - height: 0px; - color: #24292f; - pointer-events: none; - content: ''; - border: 6px solid transparent; - opacity: 0; -} - -.c3::after { - position: absolute; - z-index: 1000000; - display: none; - padding: 0.5em 0.75em; - font: normal normal 11px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; - -webkit-font-smoothing: subpixel-antialiased; - color: #ffffff; - text-align: center; - -webkit-text-decoration: none; - text-decoration: none; - text-shadow: none; - text-transform: none; - -webkit-letter-spacing: normal; - -moz-letter-spacing: normal; - -ms-letter-spacing: normal; - letter-spacing: normal; - word-wrap: break-word; - white-space: pre; - pointer-events: none; - content: attr(aria-label); - background: #24292f; - border-radius: 3px; - opacity: 0; -} - -.c3:hover::before, -.c3:active::before, -.c3:focus::before, -.c3:hover::after, -.c3:active::after, -.c3:focus::after { - display: inline-block; - -webkit-text-decoration: none; - text-decoration: none; - -webkit-animation-name: tooltip-appear; - animation-name: tooltip-appear; - -webkit-animation-duration: 0.1s; - animation-duration: 0.1s; - -webkit-animation-fill-mode: forwards; - animation-fill-mode: forwards; - -webkit-animation-timing-function: ease-in; - animation-timing-function: ease-in; - -webkit-animation-delay: 0.4s; - animation-delay: 0.4s; -} - -.c3.tooltipped-no-delay:hover::before, -.c3.tooltipped-no-delay:active::before, -.c3.tooltipped-no-delay:focus::before, -.c3.tooltipped-no-delay:hover::after, -.c3.tooltipped-no-delay:active::after, -.c3.tooltipped-no-delay:focus::after { - -webkit-animation-delay: 0s; - animation-delay: 0s; -} - -.c3.tooltipped-multiline:hover::after, -.c3.tooltipped-multiline:active::after, -.c3.tooltipped-multiline:focus::after { - display: table-cell; -} - -.c3.tooltipped-s::after, -.c3.tooltipped-se::after, -.c3.tooltipped-sw::after { - top: 100%; - right: 50%; - margin-top: 6px; -} - -.c3.tooltipped-s::before, -.c3.tooltipped-se::before, -.c3.tooltipped-sw::before { - top: auto; - right: 50%; - bottom: -7px; - margin-right: -6px; - border-bottom-color: #24292f; -} - -.c3.tooltipped-se::after { - right: auto; - left: 50%; - margin-left: -16px; -} - -.c3.tooltipped-sw::after { - margin-right: -16px; -} - -.c3.tooltipped-n::after, -.c3.tooltipped-ne::after, -.c3.tooltipped-nw::after { - right: 50%; - bottom: 100%; - margin-bottom: 6px; -} - -.c3.tooltipped-n::before, -.c3.tooltipped-ne::before, -.c3.tooltipped-nw::before { - top: -7px; - right: 50%; - bottom: auto; - margin-right: -6px; - border-top-color: #24292f; -} - -.c3.tooltipped-ne::after { - right: auto; - left: 50%; - margin-left: -16px; -} - -.c3.tooltipped-nw::after { - margin-right: -16px; -} - -.c3.tooltipped-s::after, -.c3.tooltipped-n::after { - -webkit-transform: translateX(50%); - -ms-transform: translateX(50%); - transform: translateX(50%); -} - -.c3.tooltipped-w::after { - right: 100%; - bottom: 50%; - margin-right: 6px; - -webkit-transform: translateY(50%); - -ms-transform: translateY(50%); - transform: translateY(50%); -} - -.c3.tooltipped-w::before { - top: 50%; - bottom: 50%; - left: -7px; - margin-top: -6px; - border-left-color: #24292f; -} - -.c3.tooltipped-e::after { - bottom: 50%; - left: 100%; - margin-left: 6px; - -webkit-transform: translateY(50%); - -ms-transform: translateY(50%); - transform: translateY(50%); -} - -.c3.tooltipped-e::before { - top: 50%; - right: -7px; - bottom: 50%; - margin-top: -6px; - border-right-color: #24292f; -} - -.c3.tooltipped-multiline::after { - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 250px; - word-wrap: break-word; - white-space: pre-line; - border-collapse: separate; -} - -.c3.tooltipped-multiline.tooltipped-s::after, -.c3.tooltipped-multiline.tooltipped-n::after { - right: auto; - left: 50%; - -webkit-transform: translateX(-50%); - -ms-transform: translateX(-50%); - transform: translateX(-50%); -} - -.c3.tooltipped-multiline.tooltipped-w::after, -.c3.tooltipped-multiline.tooltipped-e::after { - right: 100%; -} - -.c3.tooltipped-align-right-2::after { - right: 0; - margin-right: 0; -} - -.c3.tooltipped-align-right-2::before { - right: 15px; -} - -.c3.tooltipped-align-left-2::after { - left: 0; - margin-left: 0; -} - -.c3.tooltipped-align-left-2::before { - left: 10px; -} - @media (forced-colors:active) { .c4:focus { outline: solid 1px transparent; @@ -2429,11 +2165,10 @@ exports[`TextInput renders trailingAction text button with a tooltip 1`] = ` className="c2 TextInput-action" > + diff --git a/src/__tests__/__snapshots__/Tooltip.test.tsx.snap b/src/__tests__/__snapshots__/Tooltip.test.tsx.snap index 25e5a7b14ab..83b5ed41551 100644 --- a/src/__tests__/__snapshots__/Tooltip.test.tsx.snap +++ b/src/__tests__/__snapshots__/Tooltip.test.tsx.snap @@ -2,226 +2,104 @@ exports[`Tooltip renders consistently 1`] = ` .c0 { - position: relative; + line-height: 1; } -.c0::before { - position: absolute; - z-index: 1000001; - display: none; - width: 0px; - height: 0px; - color: #24292f; - pointer-events: none; - content: ''; - border: 6px solid transparent; - opacity: 0; +.c0:hover [data-component=tooltip], +.c0:focus-within [data-component=tooltip] { + visibility: visible; + opacity: 1; } -.c0::after { - position: absolute; - z-index: 1000000; - display: none; - padding: 0.5em 0.75em; - font: normal normal 11px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; - -webkit-font-smoothing: subpixel-antialiased; +.c1 { + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 100ms ease-in; + transition: opacity 100ms ease-in; + -webkit-transition-delay: 400ms; + transition-delay: 400ms; + background-color: #24292f; color: #ffffff; - text-align: center; - -webkit-text-decoration: none; - text-decoration: none; - text-shadow: none; - text-transform: none; - -webkit-letter-spacing: normal; - -moz-letter-spacing: normal; - -ms-letter-spacing: normal; - letter-spacing: normal; - word-wrap: break-word; - white-space: pre; - pointer-events: none; - content: attr(aria-label); - background: #24292f; border-radius: 3px; - opacity: 0; -} - -.c0:hover::before, -.c0:active::before, -.c0:focus::before, -.c0:hover::after, -.c0:active::after, -.c0:focus::after { - display: inline-block; - -webkit-text-decoration: none; - text-decoration: none; - -webkit-animation-name: tooltip-appear; - animation-name: tooltip-appear; - -webkit-animation-duration: 0.1s; - animation-duration: 0.1s; - -webkit-animation-fill-mode: forwards; - animation-fill-mode: forwards; - -webkit-animation-timing-function: ease-in; - animation-timing-function: ease-in; - -webkit-animation-delay: 0.4s; - animation-delay: 0.4s; -} - -.c0.tooltipped-no-delay:hover::before, -.c0.tooltipped-no-delay:active::before, -.c0.tooltipped-no-delay:focus::before, -.c0.tooltipped-no-delay:hover::after, -.c0.tooltipped-no-delay:active::after, -.c0.tooltipped-no-delay:focus::after { - -webkit-animation-delay: 0s; - animation-delay: 0s; + font-size: 12px; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 8px; + padding-right: 8px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + max-width: 250px; + text-align: center; + position: absolute; + z-index: 2; } -.c0.tooltipped-multiline:hover::after, -.c0.tooltipped-multiline:active::after, -.c0.tooltipped-multiline:focus::after { - display: table-cell; +.c1:before { + content: ""; + width: 0; + height: 0; + border: 5px solid transparent; + position: absolute; } -.c0.tooltipped-s::after, -.c0.tooltipped-se::after, -.c0.tooltipped-sw::after { +.c1[data-side=outside-top]::before { + border-top: 5px solid; + border-top-color: #24292f; top: 100%; - right: 50%; - margin-top: 6px; } -.c0.tooltipped-s::before, -.c0.tooltipped-se::before, -.c0.tooltipped-sw::before { - top: auto; - right: 50%; - bottom: -7px; - margin-right: -6px; +.c1[data-side=outside-bottom]::before { + border-bottom: 5px solid; border-bottom-color: #24292f; + top: -10px; } -.c0.tooltipped-se::after { - right: auto; - left: 50%; - margin-left: -16px; -} - -.c0.tooltipped-sw::after { - margin-right: -16px; -} - -.c0.tooltipped-n::after, -.c0.tooltipped-ne::after, -.c0.tooltipped-nw::after { - right: 50%; - bottom: 100%; - margin-bottom: 6px; -} - -.c0.tooltipped-n::before, -.c0.tooltipped-ne::before, -.c0.tooltipped-nw::before { - top: -7px; - right: 50%; - bottom: auto; - margin-right: -6px; - border-top-color: #24292f; -} - -.c0.tooltipped-ne::after { - right: auto; - left: 50%; - margin-left: -16px; -} - -.c0.tooltipped-nw::after { - margin-right: -16px; -} - -.c0.tooltipped-s::after, -.c0.tooltipped-n::after { - -webkit-transform: translateX(50%); - -ms-transform: translateX(50%); - transform: translateX(50%); -} - -.c0.tooltipped-w::after { - right: 100%; - bottom: 50%; - margin-right: 6px; - -webkit-transform: translateY(50%); - -ms-transform: translateY(50%); - transform: translateY(50%); -} - -.c0.tooltipped-w::before { - top: 50%; - bottom: 50%; - left: -7px; - margin-top: -6px; +.c1[data-side=outside-left]::before { + border-left: 5px solid; border-left-color: #24292f; -} - -.c0.tooltipped-e::after { - bottom: 50%; + top: calc(50% - 5px); left: 100%; - margin-left: 6px; - -webkit-transform: translateY(50%); - -ms-transform: translateY(50%); - transform: translateY(50%); } -.c0.tooltipped-e::before { - top: 50%; - right: -7px; - bottom: 50%; - margin-top: -6px; +.c1[data-side=outside-right]::before { + border-right: 5px solid; border-right-color: #24292f; + top: calc(50% - 5px); + left: -10px; } -.c0.tooltipped-multiline::after { - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 250px; - word-wrap: break-word; - white-space: pre-line; - border-collapse: separate; -} - -.c0.tooltipped-multiline.tooltipped-s::after, -.c0.tooltipped-multiline.tooltipped-n::after { - right: auto; - left: 50%; - -webkit-transform: translateX(-50%); - -ms-transform: translateX(-50%); - transform: translateX(-50%); -} - -.c0.tooltipped-multiline.tooltipped-w::after, -.c0.tooltipped-multiline.tooltipped-e::after { - right: 100%; -} - -.c0.tooltipped-align-right-2::after { - right: 0; - margin-right: 0; -} - -.c0.tooltipped-align-right-2::before { - right: 15px; +.c1[data-align=start][data-side=outside-top]::before, +.c1[data-align=start][data-side=outside-bottom]::before { + left: 8px; } -.c0.tooltipped-align-left-2::after { - left: 0; - margin-left: 0; +.c1[data-align=center][data-side=outside-top]::before, +.c1[data-align=center][data-side=outside-bottom]::before { + left: calc(50% - 4px); } -.c0.tooltipped-align-left-2::before { - left: 10px; +.c1[data-align=end][data-side=outside-top]::before, +.c1[data-align=end][data-side=outside-bottom]::before { + left: calc(100% - 16px); } + className="c0" +> + + + `; diff --git a/src/__tests__/hooks/useAnchoredPosition.test.tsx b/src/__tests__/hooks/useAnchoredPosition.test.tsx index 4038ca73c0e..4b7d38dc710 100644 --- a/src/__tests__/hooks/useAnchoredPosition.test.tsx +++ b/src/__tests__/hooks/useAnchoredPosition.test.tsx @@ -23,6 +23,7 @@ it('should should return a position', () => { expect(cb).toHaveBeenCalledTimes(2) expect(cb.mock.calls[1][0]['position']).toMatchInlineSnapshot(` Object { + "anchorAlign": "start", "anchorSide": "outside-bottom", "left": 0, "top": 4, diff --git a/src/index.ts b/src/index.ts index f4ce005e4f7..435e8e9b295 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,7 +143,7 @@ export type { } from './Timeline' export {default as Token, IssueLabelToken, AvatarToken} from './Token' export type {TokenProps} from './Token' -export {default as Tooltip} from './Tooltip' +export {Tooltip} from './Tooltip' export type {TooltipProps} from './Tooltip' export {default as Truncate} from './Truncate' export type {TruncateProps} from './Truncate' diff --git a/src/stories/ActionMenu/fixtures.stories.tsx b/src/stories/ActionMenu/fixtures.stories.tsx index 8e74c3677f9..0b077e45656 100644 --- a/src/stories/ActionMenu/fixtures.stories.tsx +++ b/src/stories/ActionMenu/fixtures.stories.tsx @@ -269,6 +269,7 @@ export function MemexTableMenu(): JSX.Element { width: 200, display: 'flex', justifyContent: 'space-between', + alignItems: 'center', p: 2, border: '1px solid', borderColor: 'border.default' diff --git a/src/stories/Tooltip.stories.tsx b/src/stories/Tooltip.stories.tsx deleted file mode 100644 index 59d034282f1..00000000000 --- a/src/stories/Tooltip.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' -import {Meta} from '@storybook/react' -import {BaseStyles, ThemeProvider, IconButton} from '..' -import Box from '../Box' -import Tooltip from '../Tooltip' -import {SearchIcon} from '@primer/octicons-react' - -export default { - title: 'Tooltip/Default', - component: Tooltip, - - decorators: [ - Story => { - return ( - - - - - - ) - } - ] -} as Meta - -export const TextTooltip = () => ( - - Text with a tooltip - -) - -export const IconButtonTooltip = () => ( - - - - - -) diff --git a/src/stories/Tooltip/examples.stories.tsx b/src/stories/Tooltip/examples.stories.tsx new file mode 100644 index 00000000000..51217c340fd --- /dev/null +++ b/src/stories/Tooltip/examples.stories.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {CodeIcon, CrossReferenceIcon, ImageIcon, MentionIcon} from '@primer/octicons-react' +import {IconButton, Box, IssueLabelToken, Tooltip} from '../..' + +export default {title: 'Composite components/Tooltip/examples', component: Tooltip} as Meta + +const IssueLabel = React.forwardRef(({text, fillColor}, ref) => { + return ( + + ) +}) + +export const TokenWithTooltip = () => { + return ( + + + + + + + + + + + + + + + ) +} + +export const ButtonWithTooltip = () => { + return ( + + + + + + + + + + + + + + + ) +} diff --git a/src/stories/Tooltip/fixtures.stories.tsx b/src/stories/Tooltip/fixtures.stories.tsx new file mode 100644 index 00000000000..401492fb3db --- /dev/null +++ b/src/stories/Tooltip/fixtures.stories.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {MentionIcon} from '@primer/octicons-react' +import {Box, Button, IssueLabelToken, Tooltip} from '../..' + +export default {title: 'Composite components/Tooltip/fixtures', component: Tooltip} as Meta + +const IssueLabel = React.forwardRef(({text, fillColor}, ref) => { + return ( + + ) +}) + +export const Direction = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export const Delay = () => { + return ( + + + + + + + + + ) +} + +export const TypeLabel = () => { + return ( + + + + ) +} + +export const AcceptsSx = () => { + return ( + + + + ) +} + +export const BackwardCompatibility = () => { + return ( + + + + + + + + + + + + ) +} diff --git a/src/stories/behaviors-anchored-position.stories.tsx b/src/stories/behaviors-anchored-position.stories.tsx new file mode 100644 index 00000000000..2917b954022 --- /dev/null +++ b/src/stories/behaviors-anchored-position.stories.tsx @@ -0,0 +1,345 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {SmileyIcon, KebabHorizontalIcon, TriangleDownIcon} from '@primer/octicons-react' +import { + BaseStyles, + Box, + ThemeProvider, + Text, + IconButton, + PageLayout, + Heading, + ActionMenu, + ActionList, + Avatar, + Label, + LabelProps +} from '..' +import {useAnchoredPosition} from '../hooks' +import type {AnchorAlignment} from '@primer/behaviors' + +export default { + title: 'Behaviors/anchoredPosition', + decorators: [ + // Note: For some reason, if you use , + // the component gets unmounted from the root every time a control changes! + Story => { + return ( + + {Story()} + + ) + } + ] +} as Meta + +type TooltipProps = { + children: string + position?: {left: number; top: number; anchorAlign: AnchorAlignment} + defaultVisible?: boolean +} +const Tooltip = React.forwardRef(({defaultVisible, position, children}, ref) => { + return ( + + + {children} + + ) +}) + +const LabelWithTooltip: React.FC = ({ + description, + defaultVisible = false, + ...props +}) => { + const labelRef = React.useRef(null) + const tooltipRef = React.useRef(null) + + const {position} = useAnchoredPosition({ + side: 'outside-bottom', + align: 'center', + anchorElementRef: labelRef, + floatingElementRef: tooltipRef + }) + + return ( + + + + {description} + + + ) +} + +export const Tooltips = () => { + const [optionsOpen, setOptionsOpen] = React.useState(false) + const optionsButtonRef = React.useRef(null) + const optionsTooltipRef = React.useRef(null) + const {position: optionsTooltipPosition} = useAnchoredPosition({ + side: 'outside-bottom', + align: 'start', + anchorElementRef: optionsButtonRef, + floatingElementRef: optionsTooltipRef + }) + + const [reactionsOpen, setReactionsOpen] = React.useState(false) + const reactionButtonRef = React.useRef(null) + const reactionTooltipRef = React.useRef(null) + const {position: reactionTooltipPosition} = useAnchoredPosition({ + side: 'outside-bottom', + align: 'start', + anchorElementRef: reactionButtonRef, + floatingElementRef: reactionTooltipRef + }) + + return ( + <> + + + + + Input validation styles #1831 + + + + + + + + + setReactionsOpen(!reactionsOpen)} + /> + + Add reaction + + + + + + {['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'].map(emoji => ( + {emoji} + ))} + + + + + + setOptionsOpen(!optionsOpen)} + /> + + Show options + + + + + + Copy link + Quote reply + + Edit + + + + + + + colebemis{' '} + added{' '} + + bug + + + collab + + + blocked + + + dependencies + + + dependencies + + + + + + + Assignees + No one – assign yourself + + + + Labels + None yet + + + + + + ) +} + +export function MemexTableMenu(): JSX.Element { + return ( + <> + + + Primer teams backlog + + + + + + Title + Assignees + Status + Labels + Repository + + + + ) +} + +const TableHeader: React.FC<{style?: Record}> = props => { + return ( + + {props.children} + + + + + + + + Sort ascending (123...) + Sort descending (123...) + + Filter by values + Group by values + + Delete file + + + + + ) +}