diff --git a/.changeset/composable-trigger-rendering.md b/.changeset/composable-trigger-rendering.md new file mode 100644 index 00000000000..2dfd72edc5b --- /dev/null +++ b/.changeset/composable-trigger-rendering.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +ActionMenu, AnchoredOverlay, Tooltip: Add experimental element-based trigger composition through `@primer/react/experimental`, with automatically merged trigger props and refs. diff --git a/.changeset/experimental-tooltip-controls.md b/.changeset/experimental-tooltip-controls.md new file mode 100644 index 00000000000..cf9da06b702 --- /dev/null +++ b/.changeset/experimental-tooltip-controls.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Tooltip: Add experimental tooltip-only suppression, controlled and uncontrolled visibility, and separate popup attributes. HTML attributes on the experimental component consistently configure its trigger. diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index e0f641963bc..abac30c74bc 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -1,9 +1,9 @@ import {page, userEvent} from 'vitest/browser' import {describe, expect, it, vi, beforeEach} from 'vitest' -import {render as HTMLRender, waitFor, act, within} from '@testing-library/react' +import {render as HTMLRender, waitFor, act, within, fireEvent} from '@testing-library/react' import rtlUserEvent from '@testing-library/user-event' import type React from 'react' -import {useRef, useState} from 'react' +import {createRef, useRef, useState} from 'react' import BaseStyles from '../BaseStyles' import {ActionMenu} from '.' import {ActionList} from '../ActionList' @@ -21,6 +21,127 @@ import type {JSX} from 'react' import {implementsClassName} from '../utils/testing' import {createRenderCounter} from '../utils/testing/profiler' import {FeatureFlags} from '../FeatureFlags' +import { + ActionMenu as ExperimentalActionMenu, + Tooltip as ExperimentalTooltip, +} from '../experimental/TriggerComposition/TriggerComposition' + +describe('ActionMenu renderAnchor', () => { + const ActionMenu = ExperimentalActionMenu + const TooltipV2 = ExperimentalTooltip + it.each([false, true])('composes a tooltip and menu on one button with merged refs (flag: %s)', async mergedRefs => { + const anchorRef = createRef() + const tooltipRef = vi.fn() + const {unmount} = HTMLRender( + + ( + { + ref(node) + tooltipRef(node) + }} + text="Choose an action" + renderAnchor={tooltipProps => ( + + )} + /> + )} + > + + + New file + + + + , + ) + const button = page.getByRole('button', {name: 'Actions', exact: true}) + expect(document.querySelectorAll('button')).toHaveLength(1) + expect(anchorRef.current).toBe(button.element()) + expect(tooltipRef).toHaveBeenLastCalledWith(button.element()) + const description = button.element().getAttribute('aria-describedby') + expect(document.getElementById(description ?? '')).toHaveTextContent('Choose an action') + await expect.element(button).toHaveAttribute('aria-haspopup', 'true') + + await userEvent.click(button) + await expect.element(button).toHaveAttribute('aria-expanded', 'true') + await expect.element(page.getByRole('menuitem', {name: 'New file'})).toHaveFocus() + await userEvent.keyboard('{Escape}') + await expect.element(button).toHaveAttribute('aria-expanded', 'false') + await expect.element(button).toHaveFocus() + + await userEvent.keyboard('{ArrowDown}') + await expect.element(page.getByRole('menuitem', {name: 'New file'})).toHaveFocus() + unmount() + expect(anchorRef.current).toBeNull() + expect(tooltipRef).toHaveBeenLastCalledWith(null) + }) + + it('lets a custom trigger cancel opening before forwarding the event', async () => { + const onOpenChange = vi.fn() + const calls: string[] = [] + HTMLRender( + ( + + )} + > + Menu contents + , + ) + const button = page.getByRole('button', {name: 'Actions', exact: true}) + await userEvent.click(button) + expect(calls).toEqual(['consumer', 'forwarded']) + expect(onOpenChange).not.toHaveBeenCalled() + await userEvent.keyboard('{ArrowDown}') + expect(onOpenChange).not.toHaveBeenCalled() + await expect.element(button).toHaveAttribute('aria-expanded', 'false') + }) + + it('preserves native disabled behavior for a custom trigger', async () => { + const onOpenChange = vi.fn() + HTMLRender( + ( + + )} + > + Menu contents + , + ) + const button = page.getByRole('button', {name: 'Actions', exact: true}) + await expect.element(button).toBeDisabled() + fireEvent.click(button.element()) + expect(onOpenChange).not.toHaveBeenCalled() + await expect.element(button).toHaveAttribute('aria-expanded', 'false') + }) +}) // Mock getAnchoredPosition for feature flag tests vi.mock('@primer/behaviors', async () => { diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index c33997690f5..455e3e0db1f 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -4,7 +4,7 @@ import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react' import type {AnchoredOverlayProps} from '../AnchoredOverlay' import {AnchoredOverlay} from '../AnchoredOverlay' import type {OverlayProps} from '../Overlay' -import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from '../hooks' +import {useMergedRefs, useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from '../hooks' import {Divider} from '../ActionList/Divider' import {ActionListContainerContext} from '../ActionList/ActionListContainerContext' import type {ButtonProps} from '../Button' @@ -20,6 +20,8 @@ import {isSlot} from '../utils/is-slot' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types/Slots' import {DialogContext} from '../Dialog/DialogContext' import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' +import type {AnchorRenderProps} from '../internal/components/RenderAnchor' +import {invariant} from '../utils/invariant' export type MenuCloseHandler = ( gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close', @@ -31,6 +33,7 @@ export type MenuContextProps = Pick< > & { onClose?: MenuCloseHandler isSubmenu?: boolean + isCustomAnchor?: boolean } const MenuContext = React.createContext({renderAnchor: null, open: false}) @@ -81,12 +84,15 @@ const mergeAnchorHandlers = (anchorProps: React.HTMLAttributes, but return mergedAnchorProps } -const Menu: FCWithSlotMarker> = ({ +type ActionMenuImplProps = ActionMenuProps & {renderAnchor?: (props: AnchorRenderProps) => React.ReactElement} + +export const ActionMenuImpl: FCWithSlotMarker> = ({ anchorRef: externalAnchorRef, open, onOpenChange, children, -}: ActionMenuProps) => { + renderAnchor: customRenderAnchor, +}: ActionMenuImplProps) => { const parentMenuContext = useContext(MenuContext) const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false) @@ -114,15 +120,37 @@ const Menu: FCWithSlotMarker> = ({ (child.type === MenuButton || child.type === Anchor || isSlot(child, Anchor) || isSlot(child, MenuButton)), ) const menuButtonChildId = React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined + const hasTooltipAnchor = + customRenderAnchor && + React.Children.toArray(children).some( + child => + React.isValidElement(child) && + (child.type === Tooltip || isSlot(child, Tooltip)) && + React.isValidElement(child.props.children) && + (child.props.children.type === MenuButton || isSlot(child.props.children, MenuButton)), + ) + invariant( + !customRenderAnchor || (!menuButtonChild && !hasTooltipAnchor), + 'Pass either `renderAnchor` or `ActionMenu.Button`/`ActionMenu.Anchor`, not both.', + ) const anchorRef = useProvidedRefOrCreate(externalAnchorRef) + const callbackAnchorRef = useMergedRefs(anchorRef, undefined) const anchorId = useId(menuButtonChildId) - let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null + const renderCustomAnchor = useMemo( + () => + customRenderAnchor + ? (props: React.HTMLAttributes) => customRenderAnchor({...props, ref: callbackAnchorRef}) + : null, + [customRenderAnchor, callbackAnchorRef], + ) + let renderAnchor: AnchoredOverlayProps['renderAnchor'] = renderCustomAnchor // 🚨 Hack for good API! // we strip out Anchor from children and pass it to AnchoredOverlay to render // with additional props for accessibility // 🚨 Accounting for Tooltip wrapping ActionMenu.Button or being a direct child of ActionMenu.Anchor. const contents = React.Children.map(children, child => { + if (customRenderAnchor) return child // Is ActionMenu.Button wrapped with Tooltip? If this is the case, our anchor is the tooltip's trigger (ActionMenu.Button's grandchild) if (child.type === Tooltip || isSlot(child, Tooltip)) { // tooltip trigger @@ -184,8 +212,9 @@ const Menu: FCWithSlotMarker> = ({ onOpen, onClose, isSubmenu, + isCustomAnchor: Boolean(customRenderAnchor), }), - [anchorRef, renderAnchor, anchorId, combinedOpenState, onOpen, onClose, isSubmenu], + [anchorRef, renderAnchor, anchorId, combinedOpenState, onOpen, onClose, isSubmenu, customRenderAnchor], ) return {contents} @@ -295,6 +324,7 @@ const Overlay: FCWithSlotMarker> = ({ onOpen, onClose, isSubmenu = false, + isCustomAnchor = false, } = React.useContext(MenuContext) as MandateProps const containerRef = React.useRef(null) @@ -326,10 +356,12 @@ const Overlay: FCWithSlotMarker> = ({ const [anchorAriaLabelledby, setAnchorAriaLabelledby] = useState(null) useLayoutEffect(() => { if (open) { - setAnchorAriaLabelledby(anchorRef.current?.getAttribute('aria-labelledby') ?? null) + setAnchorAriaLabelledby( + anchorRef.current?.getAttribute('aria-labelledby') || (isCustomAnchor ? anchorRef.current?.id : null) || null, + ) setAnchorLabelReady(true) } - }, [anchorRef, open]) + }, [anchorRef, open, isCustomAnchor]) const afterSelect = useCallback(() => onClose?.('item-select'), [onClose]) @@ -381,6 +413,7 @@ const Overlay: FCWithSlotMarker> = ({ ) } +const Menu: FCWithSlotMarker> = ActionMenuImpl Menu.displayName = 'ActionMenu' MenuButton.__SLOT__ = Symbol('ActionMenu.Button') diff --git a/packages/react/src/ActionMenu/ActionMenu.types.test.tsx b/packages/react/src/ActionMenu/ActionMenu.types.test.tsx index 2ac83271c66..1accc0c836f 100644 --- a/packages/react/src/ActionMenu/ActionMenu.types.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.types.test.tsx @@ -1,4 +1,6 @@ import {ActionMenu} from '.' +import {ActionMenu as ExperimentalActionMenu, Tooltip} from '../experimental/TriggerComposition/TriggerComposition' +import {Button} from '../Button' export function actionButtonWithoutProps() { return @@ -25,3 +27,24 @@ export function actionOverlayWithInvalidPreventFocusOnClose() { // @ts-expect-error preventFocusOnClose must be a boolean return Menu contents } + +export function actionMenuWithComposedTrigger() { + const ActionMenu = ExperimentalActionMenu + return ( + ( + ( + + )} + /> + )} + > + Menu contents + + ) +} diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index bc373025e56..cedbce6f149 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -406,17 +406,19 @@ export const AnchoredOverlay: React.FC {renderAnchor && - // eslint-disable-next-line react-hooks/refs - renderAnchor({ - ref: anchorRef, - id: anchorId, - 'aria-haspopup': 'true', - 'aria-expanded': open, - tabIndex: 0, - onClick: onAnchorClick, - onKeyDown: onAnchorKeyDown, - ...popoverTargetProps, - })} + renderAnchor( + // eslint-disable-next-line react-hooks/refs -- the callback forwards the object ref to an element + { + ref: anchorRef, + id: anchorId, + 'aria-haspopup': 'true', + 'aria-expanded': open, + tabIndex: 0, + onClick: onAnchorClick, + onKeyDown: onAnchorKeyDown, + ...popoverTargetProps, + }, + )} {open ? ( ) } + +export function itemWithTooltip() { + return ( + Info} />} + > +

GitHub

+
+ ) +} + +export function invokeExistingAnchor(renderAnchor: AnchoredOverlayProps['renderAnchor']) { + return renderAnchor?.({'aria-expanded': false}) +} diff --git a/packages/react/src/TooltipV2/Tooltip.tsx b/packages/react/src/TooltipV2/Tooltip.tsx index 82cd3c36ed0..00f461ae010 100644 --- a/packages/react/src/TooltipV2/Tooltip.tsx +++ b/packages/react/src/TooltipV2/Tooltip.tsx @@ -15,6 +15,8 @@ import useSafeTimeout from '../hooks/useSafeTimeout' import type {SlotMarker} from '../utils/types' import {TooltipContext} from './TooltipContext' import {reactMajorVersion} from '../utils/environment' +import type {AnchorRenderProps} from '../internal/components/RenderAnchor' +import {useEffectCallback} from '../internal/hooks/useEffectCallback' export type TooltipDirection = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' export type TooltipProps = React.PropsWithChildren<{ @@ -42,21 +44,6 @@ export type TooltipProps = React.PropsWithChildren<{ }> & React.HTMLAttributes -type TriggerPropsType = Pick< - React.HTMLAttributes, - | 'aria-describedby' - | 'aria-labelledby' - | 'onBlur' - | 'onTouchEnd' - | 'onFocus' - | 'onMouseOverCapture' - | 'onMouseLeave' - | 'onTouchCancel' - | 'onTouchEnd' -> & { - ref?: React.Ref -} - // map tooltip direction to anchoredPosition props const directionToPosition: Record = { nw: {side: 'outside-top', align: 'end'}, @@ -107,27 +94,49 @@ const isInteractive = (element: HTMLElement) => { } const emptyKeybindingHints: Array = [] -export const Tooltip: ForwardRefExoticComponent< - React.PropsWithoutRef & React.RefAttributes +export type TooltipOpenChangeDetails = { + reason: 'trigger-hover' | 'trigger-focus' | 'trigger-press' | 'escape-key' | 'outside-press' | 'disabled' | 'none' + event?: Event | React.SyntheticEvent +} + +type TooltipImplProps = TooltipProps & { + popover?: 'auto' | 'manual' | '' + renderAnchor?: (props: AnchorRenderProps) => React.ReactElement + visibility?: { + open: boolean + controlled: boolean + onOpenChange: (open: boolean, details: TooltipOpenChangeDetails) => void + } +} + +export const TooltipImpl: ForwardRefExoticComponent< + React.PropsWithoutRef & React.RefAttributes > & - SlotMarker = React.forwardRef( + SlotMarker = React.forwardRef( ( { direction = 's', text, type = 'description', children, + renderAnchor, + visibility, + popover, id, className, keybindingHint = emptyKeybindingHints, delay = 'short', _privateDisableTooltip = false, ...rest - }: TooltipProps, + }: TooltipImplProps, forwardedRef, ) => { - const tooltipId = useId(id) - const child = Children.only(children) + const hasAriaLabel = 'aria-label' in rest + const keybindingHints = Array.isArray(keybindingHint) ? keybindingHint : [keybindingHint] + const tooltipId = useId(visibility && keybindingHints.length > 0 && !hasAriaLabel ? undefined : id) + const hasRenderAnchor = renderAnchor !== undefined + invariant(!renderAnchor || children == null, 'Pass either `children` or `renderAnchor` to `Tooltip`, not both.') + const child = renderAnchor ? undefined : Children.only(children) const isFragmentTrigger = React.isValidElement(child) && child.type === React.Fragment warning( isFragmentTrigger, @@ -142,8 +151,8 @@ export const Tooltip: ForwardRefExoticComponent< // Feature-flag scaffolding for `primer_react_merged_forwarded_refs`. // At graduation: remove the three declarations below, and replace all instances of `readTriggerRef` with `triggerRef` and `appliedTriggerRef` with `mergedTriggerRef`. const providedOrCreatedRef = useProvidedRefOrCreate((forwardedRef ?? childRef) as React.RefObject) - const readTriggerRef = mergedRefEnabled ? triggerRef : providedOrCreatedRef - const appliedTriggerRef = mergedRefEnabled ? mergedTriggerRef : providedOrCreatedRef + const readTriggerRef = renderAnchor || mergedRefEnabled ? triggerRef : providedOrCreatedRef + const appliedTriggerRef = renderAnchor || mergedRefEnabled ? mergedTriggerRef : providedOrCreatedRef const tooltipElRef = useRef(null) const [calculatedDirection, setCalculatedDirection] = useState(direction) @@ -153,8 +162,9 @@ export const Tooltip: ForwardRefExoticComponent< const openTimeoutRef = React.useRef(null) const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + const tooltipPopover = visibility ? (visibility.controlled ? 'manual' : (popover ?? 'auto')) : 'auto' - const openTooltip = () => { + const showPopover = useEffectCallback(() => { try { if ( tooltipElRef.current && @@ -165,6 +175,7 @@ export const Tooltip: ForwardRefExoticComponent< ) { const tooltip = tooltipElRef.current const trigger = readTriggerRef.current + if (hasRenderAnchor && trigger.matches(':disabled')) return tooltip.showPopover() setIsPopoverOpen(true) /* @@ -196,12 +207,15 @@ export const Tooltip: ForwardRefExoticComponent< throw error } } - } - const closeTooltip = () => { - if (openTimeoutRef.current) { + }) + const clearOpenTimeout = useEffectCallback(() => { + if (openTimeoutRef.current !== null) { safeClearTimeout(openTimeoutRef.current) openTimeoutRef.current = null } + }) + const hidePopover = useEffectCallback(() => { + clearOpenTimeout() try { if ( tooltipElRef.current && @@ -229,12 +243,31 @@ export const Tooltip: ForwardRefExoticComponent< throw error } } - } + }) + const openTooltip = useEffectCallback((details: TooltipOpenChangeDetails = {reason: 'trigger-hover'}) => { + if (visibility) { + if (!_privateDisableTooltip && !readTriggerRef.current?.matches(':disabled')) { + visibility.onOpenChange(true, details) + } + } else { + showPopover() + } + }) + const closeTooltip = useEffectCallback((details: TooltipOpenChangeDetails = {reason: 'trigger-hover'}) => { + clearOpenTimeout() + if (visibility) { + visibility.onOpenChange(false, details) + } else { + hidePopover() + } + }) // context value const value = useMemo(() => ({tooltipId}), [tooltipId]) useEffect(() => { + // The renderAnchor path intentionally selects the internal ref used by this effect. + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (!tooltipElRef.current || !readTriggerRef.current) return const trigger = readTriggerRef.current const isInvalidTrigger = !(trigger instanceof HTMLElement) @@ -242,12 +275,13 @@ export const Tooltip: ForwardRefExoticComponent< isInvalidTrigger, 'The `Tooltip` component expects its trigger ref to resolve to an HTML element. Ensure the trigger forwards its ref to a single interactive element instead of a React Fragment.', ) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (isInvalidTrigger) return /* * ACCESSIBILITY CHECKS */ // Has trigger element or any of its children interactive elements? - const isTriggerInteractive = isInteractive(trigger) + const isTriggerInteractive = isInteractive(trigger) || (hasRenderAnchor && trigger.matches(':disabled')) const triggerChildren = trigger.childNodes // two levels deep const hasInteractiveDescendant = Array.from(triggerChildren).some(child => { @@ -283,8 +317,54 @@ export const Tooltip: ForwardRefExoticComponent< } const tooltip = tooltipElRef.current - tooltip.setAttribute('popover', 'auto') - }, [tooltipElRef, readTriggerRef, direction, type]) + tooltip.setAttribute('popover', tooltipPopover) + }, [tooltipElRef, readTriggerRef, direction, type, hasRenderAnchor, tooltipPopover]) + + const synchronizeVisibility = useEffectCallback(() => { + if (!visibility) return + if (visibility.open && !_privateDisableTooltip) { + showPopover() + } else { + hidePopover() + } + if (_privateDisableTooltip) closeTooltip({reason: 'disabled'}) + }) + useEffect(() => { + // Synchronize React-owned visibility with the native Popover API. + synchronizeVisibility() + }, [visibility?.open, _privateDisableTooltip, synchronizeVisibility]) + + const onNativeToggle = useEffectCallback((event: Event) => { + const tooltip = tooltipElRef.current + if (!tooltip || !visibility || visibility.controlled) return + if (!tooltip.matches(':popover-open') && visibility.open) { + clearOpenTimeout() + visibility.onOpenChange(false, {reason: 'none', event}) + } + }) + useEffect(() => { + const tooltip = tooltipElRef.current + if (!visibility || !tooltip) return + tooltip.addEventListener('toggle', onNativeToggle) + return () => tooltip.removeEventListener('toggle', onNativeToggle) + }, [visibility, onNativeToggle]) + + const onOutsidePress = useEffectCallback((event: PointerEvent) => { + if ( + visibility?.controlled && + tooltipElRef.current?.matches(':popover-open') && + event.target instanceof Node && + !readTriggerRef.current?.contains(event.target) && + !tooltipElRef.current.contains(event.target) + ) { + closeTooltip({reason: 'outside-press', event}) + } + }) + useEffect(() => { + if (!visibility?.controlled) return + document.addEventListener('pointerdown', onOutsidePress) + return () => document.removeEventListener('pointerdown', onOutsidePress) + }, [visibility?.controlled, onOutsidePress]) useOnEscapePress( (event: KeyboardEvent) => { @@ -293,101 +373,110 @@ export const Tooltip: ForwardRefExoticComponent< event.stopImmediatePropagation() event.preventDefault() } - closeTooltip() + closeTooltip({reason: 'escape-key', event}) } }, [isPopoverOpen, _privateDisableTooltip], ) const platform = usePlatform() - const hasAriaLabel = 'aria-label' in rest - - // Normalize keybindingHint to an array for uniform rendering - const keybindingHints = Array.isArray(keybindingHint) ? keybindingHint : [keybindingHint] if (isFragmentTrigger) return child - return ( - - <> - {React.isValidElement(child) && - React.cloneElement(child as React.ReactElement, { - // @ts-expect-error the provided-or-created ref path needs a non nullable ref - ref: appliedTriggerRef, - // If it is a type description, we use tooltip to describe the trigger - 'aria-describedby': (() => { - // If tooltip is not a description type, keep the original aria-describedby - if (type !== 'description') { - return child.props['aria-describedby'] - } - - // If tooltip is a description type, append our tooltipId - const existingDescribedBy = child.props['aria-describedby'] - if (existingDescribedBy) { - return `${existingDescribedBy} ${tooltipId}` - } + const triggerProps: React.HTMLAttributes & React.RefAttributes = { + // @ts-expect-error the provided-or-created ref path needs a non nullable ref + ref: appliedTriggerRef, + ...(type === 'description' + ? {'aria-describedby': [elementChild?.props['aria-describedby'], tooltipId].filter(Boolean).join(' ')} + : {}), + ...(type === 'label' ? {'aria-labelledby': tooltipId} : {}), + onBlur: (event: React.FocusEvent) => { + closeTooltip({reason: 'trigger-focus', event}) + elementChild?.props.onBlur?.(event) + }, + onTouchEnd: (event: React.TouchEvent) => { + elementChild?.props.onTouchEnd?.(event) - // If no existing aria-describedby, use our tooltipId - return tooltipId - })(), - // If it is a label type, we use tooltip to label the trigger - 'aria-labelledby': type === 'label' ? tooltipId : child.props['aria-labelledby'], - onBlur: (event: React.FocusEvent) => { - closeTooltip() - child.props.onBlur?.(event) - }, - onTouchEnd: (event: React.TouchEvent) => { - child.props.onTouchEnd?.(event) + // Hide tooltips on tap to essentially disable them on touch devices; + // this still allows viewing the tooltip on tap-and-hold + safeSetTimeout(() => closeTooltip({reason: 'trigger-press', event}), 10) + }, + onFocus: (event: React.FocusEvent) => { + // only show tooltip on :focus-visible, not on :focus + try { + if (!event.target.matches(':focus-visible')) return + } catch (_error) { + // jsdom (jest) does not support `:focus-visible` yet and would throw an error + // https://github.com/jsdom/jsdom/issues/3426 + } - // Hide tooltips on tap to essentially disable them on touch devices; - // this still allows viewing the tooltip on tap-and-hold - safeSetTimeout(() => closeTooltip(), 10) - }, - onFocus: (event: React.FocusEvent) => { - // only show tooltip on :focus-visible, not on :focus - try { - if (!event.target.matches(':focus-visible')) return - } catch (_error) { - // jsdom (jest) does not support `:focus-visible` yet and would throw an error - // https://github.com/jsdom/jsdom/issues/3426 - } + openTooltip({reason: 'trigger-focus', event}) + elementChild?.props.onFocus?.(event) + }, + onMouseOverCapture: (event: React.MouseEvent) => { + if (visibility && _privateDisableTooltip) return + clearOpenTimeout() + const delayTime = delayTimeMap[delay] || 50 + // We use a `capture` event to ensure this is called first before + // events that might cancel the opening timeout (like `onTouchEnd`) + // show tooltip after mouse has been hovering for the specified delay time + // (prevent showing tooltip when mouse is just passing through) + openTimeoutRef.current = safeSetTimeout(() => { + // if the mouse is already moved out, do not show the tooltip + if (openTimeoutRef.current === null) return + openTimeoutRef.current = null + openTooltip({reason: 'trigger-hover', event}) + elementChild?.props.onMouseEnter?.(event) + }, delayTime) + }, + onMouseLeave: (event: React.MouseEvent) => { + if ( + !(visibility && event.relatedTarget instanceof Node && tooltipElRef.current?.contains(event.relatedTarget)) + ) { + closeTooltip({reason: 'trigger-hover', event}) + } + elementChild?.props.onMouseLeave?.(event) + }, + } - openTooltip() - child.props.onFocus?.(event) - }, - onMouseOverCapture: (event: React.MouseEvent) => { - const delayTime = delayTimeMap[delay] || 50 - // We use a `capture` event to ensure this is called first before - // events that might cancel the opening timeout (like `onTouchEnd`) - // show tooltip after mouse has been hovering for the specified delay time - // (prevent showing tooltip when mouse is just passing through) - openTimeoutRef.current = safeSetTimeout(() => { - // if the mouse is already moved out, do not show the tooltip - if (!openTimeoutRef.current) return - openTooltip() - child.props.onMouseEnter?.(event) - }, delayTime) - }, - onMouseLeave: (event: React.MouseEvent) => { - closeTooltip() - child.props.onMouseLeave?.(event) - }, - })} + return ( + + <> + {renderAnchor + ? renderAnchor({...triggerProps, ref: mergedTriggerRef}) + : React.isValidElement(child) && + React.cloneElement(child as React.ReactElement, triggerProps)} { + if (visibility) rest.onMouseEnter?.(event) + if (!event.defaultPrevented) openTooltip({reason: 'trigger-hover', event}) + }} + onMouseLeave={event => { + if (visibility) rest.onMouseLeave?.(event) + if ( + !event.defaultPrevented && + !( + visibility && + event.relatedTarget instanceof Node && + readTriggerRef.current?.contains(event.relatedTarget) + ) + ) { + closeTooltip({reason: 'trigger-hover', event}) + } + }} // If there is an aria-label prop, always assign the ID to the parent so the accessible label can be overridden - id={hasAriaLabel || keybindingHints.length === 0 ? tooltipId : undefined} + id={hasAriaLabel || keybindingHints.length === 0 ? tooltipId : visibility ? id : undefined} > {keybindingHints.length > 0 ? ( <> @@ -429,4 +518,8 @@ export const Tooltip: ForwardRefExoticComponent< }, ) +export const Tooltip: ForwardRefExoticComponent< + React.PropsWithoutRef & React.RefAttributes +> & + SlotMarker = TooltipImpl Tooltip.__SLOT__ = Symbol('Tooltip') diff --git a/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx b/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx index 46ca86f8d88..8ee1d4e105a 100644 --- a/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx +++ b/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx @@ -6,15 +6,17 @@ import {fireEvent, render as HTMLRender} from '@testing-library/react' import BaseStyles from '../../BaseStyles' import {Button, IconButton} from '../../Button' import {ActionMenu} from '../../ActionMenu' +import {AnchoredOverlay, Tooltip as ExperimentalTooltip} from '../../experimental/TriggerComposition/TriggerComposition' import {ActionList} from '../../ActionList' import ButtonGroup from '../../ButtonGroup' import {XIcon} from '@primer/octicons-react' import classes from '../Tooltip.module.css' import type {JSX} from 'react' -import {createRef, forwardRef, useImperativeHandle} from 'react' +import {createRef, forwardRef, useImperativeHandle, useState} from 'react' import {FeatureFlags} from '../../FeatureFlags' import {implementsClassName, withExpectedConsoleError, withExpectedConsoleWarning} from '../../utils/testing' +import {page, userEvent} from 'vitest/browser' const TooltipComponent = (props: Omit & {text?: string}) => ( @@ -59,6 +61,120 @@ function ExampleWithActionMenu({actionMenuTrigger}: {actionMenuTrigger: React.Re } describe('Tooltip', () => { + describe('experimental renderAnchor', () => { + const Tooltip = ExperimentalTooltip + it('preserves a composed trigger label and explicitly merges its existing description IDs', () => { + const anchorProps = {'aria-labelledby': 'anchor-label', 'aria-describedby': 'anchor-description'} + HTMLRender( + <> + Existing label + Existing description + ( + } />} + > + + + ) + } + + HTMLRender() + const button = page.getByRole('button', {name: 'Pages', exact: true}) + expect(document.querySelectorAll('button')).toHaveLength(1) + await userEvent.click(button) + await expect.element(button).toHaveAttribute('aria-expanded', 'true') + const close = page.getByRole('button', {name: 'Close details', exact: true}) + await expect.element(close).toHaveFocus() + await userEvent.click(close) + await expect.element(button).toHaveAttribute('aria-expanded', 'false') + await expect.element(button).toHaveFocus() + }) + + it('renders and labels a custom trigger with a callback ref without requiring a feature flag', async () => { + const ref = createRef() + const {unmount} = HTMLRender( + + )} + > + + , + ), + ).toThrow('Pass either `children` or `renderAnchor` to `Tooltip`, not both.') + }) + }) + }) + implementsClassName(TooltipComponent, classes.Tooltip) it('renders `data-direction="s"` by default', () => { const {getByText} = HTMLRender() diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index ee096520739..d08150ad1e5 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -302,6 +302,10 @@ exports[`@primer/react/experimental > should not update exports without a semver "type ActionBarMenuItemProps", "type ActionBarMenuProps", "type ActionBarProps", + "ActionMenu", + "type ActionMenuProps", + "AnchoredOverlay", + "type AnchoredOverlayProps", "Announce", "type AnnounceProps", "AriaAlert", @@ -407,6 +411,7 @@ exports[`@primer/react/experimental > should not update exports without a semver "type TabsProps", "type TitleProps", "Tooltip", + "type TooltipOpenChangeDetails", "type TooltipProps", "TopicTag", "type TopicTagGroupProps", diff --git a/packages/react/src/experimental/TriggerComposition/README.mdx b/packages/react/src/experimental/TriggerComposition/README.mdx new file mode 100644 index 00000000000..669edf2af23 --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/README.mdx @@ -0,0 +1,118 @@ +import {Meta} from '@storybook/addon-docs/blocks' + + + +# Experimental trigger composition + +The `@primer/react/experimental` versions of `ActionMenu`, `AnchoredOverlay`, and +`Tooltip` accept an element in `renderAnchor`. Like +[Base UI's render prop](https://base-ui.com/react/handbook/composition), this +composes behaviors onto one interactive element, automatically merging props and +refs. It does not add a DOM wrapper or require separate Root/Trigger components. + +The stable exports remain unchanged. The experimental `Tooltip` also accepts +children, with trigger-directed HTML props and separate `popupProps`, and configured `ActionMenu.Button`/`ActionMenu.Anchor` +usage remains supported. `SelectPanel` is not part of this experiment. + +## Menu and tooltip + +```tsx +import {ActionList, Button} from '@primer/react' +import {ActionMenu, Tooltip} from '@primer/react/experimental' +; event.stopPropagation()}>Owner} /> + } +> + + + Assign me + + + +``` + +The button's handler and menu's toggle both run. The caller does not extract a +ref, forward an internal handler, or merge tooltip description IDs. An existing +button ref continues to receive that same button, alongside the behaviors' refs. + +Configured menus already render one native button, too. The benefit is not fewer +DOM nodes: element rendering removes the need for the menu to recognize specific +tooltip/anchor nesting patterns, and gives composed behaviors a consistent +prop-merging contract. + +## Direct overlay and tooltip + +```tsx +import {IconButton} from '@primer/react' +import {AnchoredOverlay, Tooltip} from '@primer/react/experimental' +import {InfoIcon} from '@primer/octicons-react' +; setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={ + } + /> + } +> + {children} + +``` + +The overlay and tooltip use the same DOM anchor for positioning, outside-click +handling, and focus return. No broad object ref needs to be spread into a more +narrowly typed button ref. This does not introduce an independent focus-return +target or change overlay state management. + +## Element merging contract + +- **Refs:** All behavior, forwarded, and render-element refs receive the same DOM + node; React 19 cleanup functions are preserved. +- **Event handlers:** The render element's handler runs first, followed by + supplied handlers unless `defaultPrevented`. `stopPropagation()` does not + cancel the internal handler. +- **Classes:** Both `className` values are combined, render element first. +- **Styles:** Shallow merge; the render element's values win. +- **ARIA relationships:** `aria-labelledby` and `aria-describedby` ID lists are + combined, render element first, removing duplicates. An omitted or undefined + relationship does not erase another behavior's IDs. +- **Other attributes:** The render element's value wins, including `id`. Menu + labelling follows the actual anchor's ID or label relationship. + +Two deliberate differences from Base UI apply: Primer uses the existing +`preventDefault()` cancellation convention rather than adding +`preventBaseUIHandler()`, and preserves ARIA ID lists rather than overwriting +them. Element-first event ordering is an intentional exception to Primer's +usual component-first prop-merging convention for this experiment. + +See the [experimental Tooltip documentation](?path=/docs/experimental-components-tooltip--docs) +for tooltip attribute routing and accessible naming. + +## Responsibilities and escape hatches + +- Use `renderAnchor` or a configured menu anchor, not both. Similarly, provide + either Tooltip children or `renderAnchor`. Never nest native buttons. +- Custom render elements must forward their ref and received trigger props to + the interactive DOM element, not a wrapper or imperative handle. +- Disabled behavior is not a generic boolean merge. Configure the actual + rendered control using its normal `disabled` or `inactive` API. Native disabled + controls cannot open the overlay or tooltip. `aria-disabled` controls cannot + activate the overlay but remain focusable for an explanatory tooltip; Tab is + not cancelled. Tooltip's own `disabled` prop suppresses only the tooltip; + it never disables the rendered control. Set `disabled` on the render element + itself to disable that control. +- Manage controlled open state normally. Composing triggers does not arbitrate + between independently controlled menus, dialogs, or overlays. + +Function-valued `renderAnchor` remains an escape hatch. It receives the supplied +props and ref, but the caller owns how those props are applied and merged with +the returned element's props. Spread order alone cannot preserve overlapping +handlers or ARIA IDs. Do not call hooks inside render callbacks; render a +component when hooks are needed. + +The original `AnchoredOverlay` callback still receives one argument with its +original runtime object ref. There is no new second-argument ref context. diff --git a/packages/react/src/experimental/TriggerComposition/Tooltip.examples.stories.tsx b/packages/react/src/experimental/TriggerComposition/Tooltip.examples.stories.tsx new file mode 100644 index 00000000000..d382c399fa1 --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/Tooltip.examples.stories.tsx @@ -0,0 +1,49 @@ +import {useState} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {ActionList} from '../../ActionList' +import {Button} from '../../Button' +import {ActionMenu, Tooltip} from './TriggerComposition' + +const meta = { + title: 'Experimental/Components/Tooltip/Examples', + component: Tooltip, + args: {text: 'Choose an action'}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const CoordinateWithMenu: Story = { + render: function CoordinateWithMenu() { + const [menuOpen, setMenuOpen] = useState(false) + return ( + Actions} + /> + } + > + + + Edit + Duplicate + + + + ) + }, + parameters: { + docs: { + description: { + story: + 'Use the menu state to suppress its tooltip with disabled, without a second visibility state or private API. The composed trigger stays enabled, its accessible description is preserved, and refs and handlers merge automatically.', + }, + }, + }, +} diff --git a/packages/react/src/experimental/TriggerComposition/Tooltip.features.stories.tsx b/packages/react/src/experimental/TriggerComposition/Tooltip.features.stories.tsx new file mode 100644 index 00000000000..85595200151 --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/Tooltip.features.stories.tsx @@ -0,0 +1,67 @@ +import {useState} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {Button} from '../../Button' +import {Tooltip} from './TriggerComposition' + +const meta = { + title: 'Experimental/Components/Tooltip/Features', + component: Tooltip, + args: { + text: 'Additional context', + renderAnchor: , + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ControlledVisibility: Story = { + render: function ControlledVisibility(args) { + const [open, setOpen] = useState(false) + const [lastRequest, setLastRequest] = useState('None') + return ( + <> + {' '} + {' '} + { + setLastRequest(`${next ? 'Open' : 'Close'}: ${reason}`) + setOpen(next) + }} + /> +

Last interaction request: {lastRequest}

+ + ) + }, + parameters: { + docs: { + description: { + story: + 'Show tooltip and Hide tooltip change visibility without focusing the trigger. Hover, keyboard focus, blur, and Escape request changes through onOpenChange; open remains authoritative. Updating open itself does not emit another request.', + }, + }, + }, +} + +export const SeparatePopupAttributes: Story = { + args: { + id: 'action-trigger', + className: 'action-trigger', + popupProps: { + id: 'action-tooltip', + className: 'action-tooltip', + style: {fontWeight: 'var(--base-text-weight-semibold)'}, + }, + text: 'The trigger and popup each receive their own HTML attributes.', + }, + parameters: { + docs: { + description: { + story: + 'Incoming HTML props configure the trigger. popupProps configures the bubble independently, including its className, id, style, and handlers. The ref still points to the trigger.', + }, + }, + }, +} diff --git a/packages/react/src/experimental/TriggerComposition/Tooltip.mdx b/packages/react/src/experimental/TriggerComposition/Tooltip.mdx new file mode 100644 index 00000000000..45c94983655 --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/Tooltip.mdx @@ -0,0 +1,121 @@ +import {Meta} from '@storybook/addon-docs/blocks' + + + +# Tooltip + +Import `Tooltip` from `@primer/react/experimental` to compose tooltip behavior +with another trigger using an element-valued `renderAnchor`. The stable export +is unchanged. + +```tsx +import {Button} from '@primer/react' +import {Tooltip} from '@primer/react/experimental' +;Run action} /> +``` + +See [trigger composition](?path=/docs/experimental-trigger-composition--docs) +for menu and overlay examples, merged refs, handler ordering, ARIA ID merging, +and callback escape hatches. + +## Attribute routing + +HTML attributes such as `id`, `className`, `style`, `popover`, +ARIA attributes, and handlers configure the **trigger**, not the tooltip bubble. +`text`, `type`, `direction`, `delay`, and `keybindingHint` still configure the +tooltip. Use `popupProps` for the bubble's HTML attributes in both `renderAnchor` +and children mode: + +```tsx +Run action} +/> +``` + +This follows Base UI's separation between `Tooltip.Trigger` and `Tooltip.Popup`, +flattened into Primer's existing component shape. Base UI uses separate parts, +not a `popupProps` prop. + +When migrating the stable children API, move bubble attributes into `popupProps`. +The experimental children syntax remains supported, but its HTML props now +consistently configure the trigger. Tooltip ARIA relationships, bubble role, +and `aria-hidden` remain managed internally. The forwarded `ref` targets the +trigger. An explicit popup `id` stays on the bubble. With keybinding hints, the +accessible relationship may reference an internal text node instead, avoiding +duplicate key names. + +Provide either children or `renderAnchor`, not both. Custom render elements +must forward received props and their ref to the interactive DOM element. + +## Accessible naming + +Use a description tooltip for a button that already has an accessible name; +use a label tooltip for an otherwise unnamed trigger. Existing description IDs +on the button are preserved automatically in element mode. + +## Visibility + +The tooltip is uncontrolled by default. `defaultOpen` sets only its initial +visibility. Pass `open` and `onOpenChange` to control visibility: + +```tsx +const [open, setOpen] = useState(false) + + setOpen(next)} + text="Run the action" + renderAnchor={} +/> +``` + +`onOpenChange(next, details)` reports interaction requests, not a notification +that a controlled bubble has changed. Hover requests opening after `delay`; +keyboard focus requests it immediately. Leaving the trigger, blur, touch, +Escape, and outside dismissal request closing. A supplied `open` remains +authoritative: if the owner ignores a request, visibility does not change. +Updating `open` directly and initial `defaultOpen` do not invoke the callback. +Choose controlled or uncontrolled mode for the component's lifetime. + +`details.reason` identifies `trigger-hover`, `trigger-focus`, `trigger-press`, +`escape-key`, `outside-press`, `disabled`, or `none` (native dismissal). +`details.event`, when available, contains the originating event. Accept closing +requests, especially Escape, to keep a controlled tooltip dismissible. + +Controlled tooltips use `popover="manual"` internally so native light dismissal +cannot override `open`; this takes precedence over `popupProps.popover`. +Uncontrolled tooltips default to `popover="auto"` and synchronize native +dismissal with their state. + +### Tooltip-only suppression + +Use `disabled` to suppress the tooltip without disabling or remounting its +trigger. For example, reuse a menu's existing state without adding tooltip state: + +```tsx +Actions} /> +``` + +Like Base UI's `Tooltip.Root disabled`, this is tooltip behavior, not a native +trigger attribute. Base UI's `Tooltip.Trigger disabled` also suppresses tooltip +interactions rather than disabling its DOM element. To disable the control +itself, put `disabled` on the rendered element, such as +`renderAnchor={}`. + +Suppression takes precedence over `open` and `defaultOpen`, closes a visible bubble, +cancels pending opens, and blocks hover/focus reopening while retaining accessible +labels and descriptions. `onOpenChange` requests closing with reason `disabled` +when an open tooltip is suppressed, even if a controlled owner ignores it. +If the owner keeps `open={true}`, removing suppression makes that value effective +again. In uncontrolled mode a fresh interaction is required after re-enabling. + +The existing `_privateDisableTooltip` prop remains a compatibility alias. +Either prop being `true` suppresses the tooltip, even when the other is `false`. +Use public `disabled` for new code; neither prop is forwarded to the trigger. + +See the **Controlled visibility**, **Separate popup attributes**, and +**Coordinate with menu** stories for interactive examples. These state controls +do not add positioning overrides, content portals, or new ARIA naming modes. diff --git a/packages/react/src/experimental/TriggerComposition/Tooltip.stories.tsx b/packages/react/src/experimental/TriggerComposition/Tooltip.stories.tsx new file mode 100644 index 00000000000..16a27a5f145 --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/Tooltip.stories.tsx @@ -0,0 +1,19 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {Button} from '../../Button' +import {Tooltip} from './TriggerComposition' + +const meta = { + title: 'Experimental/Components/Tooltip', + component: Tooltip, + args: { + text: 'Additional context', + renderAnchor: , + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const Playground: Story = {} diff --git a/packages/react/src/experimental/TriggerComposition/TriggerComposition.test.tsx b/packages/react/src/experimental/TriggerComposition/TriggerComposition.test.tsx new file mode 100644 index 00000000000..219e548c6fc --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/TriggerComposition.test.tsx @@ -0,0 +1,612 @@ +import type React from 'react' +import {createRef, useState} from 'react' +import {act, fireEvent, render} from '@testing-library/react' +import {afterEach, describe, expect, it, vi} from 'vitest' +import {page, userEvent} from 'vitest/browser' +import {InfoIcon} from '@primer/octicons-react' +import {ActionMenu, AnchoredOverlay, Tooltip} from './TriggerComposition' +import {ActionMenu as StableActionMenu} from '../../ActionMenu' +import {ActionList} from '../../ActionList' +import {Button, IconButton} from '../../Button' +import {FeatureFlags} from '../../FeatureFlags' +import {implementsClassName, withExpectedConsoleError} from '../../utils/testing' +import {reactMajorVersion} from '../../utils/environment' + +describe('experimental Tooltip routing and visibility', () => { + afterEach(() => vi.useRealTimers()) + + describe.each(['children', 'renderAnchor'] as const)('trigger className in %s mode', mode => { + implementsClassName(({className}: {className?: string}) => { + const trigger = + return ( + + ) + }, 'tooltip-trigger') + }) + + it.each(['children', 'renderAnchor'] as const)('separates trigger and popup attributes in %s mode', mode => { + const onPopupEnter = vi.fn() + const buttonRef = createRef() + const trigger = ( + + ) + const {getByText} = render( + , + ) + const button = page.getByRole('button', {name: 'Action', exact: true}).element() + const popup = getByText('Additional context') + expect(buttonRef.current).toBe(button) + expect(button).toHaveAttribute('id', 'action-trigger') + expect(button).toHaveClass('trigger-class') + expect(button).not.toHaveClass('popup-class') + expect(button).toHaveStyle({color: 'rgb(0, 0, 255)'}) + expect(button).not.toHaveAttribute('popover') + expect(button).toHaveAccessibleDescription('Additional context') + expect(button).toHaveAttribute('aria-describedby', 'action-tooltip') + expect(popup).toHaveClass('popup-class') + expect(popup).not.toHaveClass('trigger-class') + expect(popup).toHaveStyle({color: 'rgb(255, 0, 0)'}) + expect(popup).toHaveAttribute('popover', 'manual') + expect(popup).toHaveAttribute('aria-hidden', 'true') + fireEvent.mouseEnter(popup) + expect(onPopupEnter).toHaveBeenCalledOnce() + expect(popup.matches(':popover-open')).toBe(true) + }) + + it('honors controlled visibility and treats interactions as requests', () => { + vi.useFakeTimers() + const onOpenChange = vi.fn() + const props = { + text: 'Additional context', + onOpenChange, + delay: 'medium' as const, + renderAnchor: , + } + const {getByText, rerender} = render() + const button = page.getByRole('button', {name: 'Action', exact: true}).element() + const popup = getByText('Additional context') + fireEvent.mouseOver(button) + act(() => vi.advanceTimersByTime(399)) + expect(onOpenChange).not.toHaveBeenCalled() + expect(popup.matches(':popover-open')).toBe(false) + act(() => vi.advanceTimersByTime(1)) + expect(onOpenChange).toHaveBeenLastCalledWith(true, expect.objectContaining({reason: 'trigger-hover'})) + expect(popup.matches(':popover-open')).toBe(false) + + rerender() + expect(popup.matches(':popover-open')).toBe(true) + expect(onOpenChange).toHaveBeenCalledOnce() + fireEvent.focusOut(button) + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.objectContaining({reason: 'trigger-focus'})) + expect(popup.matches(':popover-open')).toBe(true) + fireEvent.keyDown(document, {key: 'Escape'}) + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.objectContaining({reason: 'escape-key'})) + expect(popup.matches(':popover-open')).toBe(true) + fireEvent.pointerDown(document.body) + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.objectContaining({reason: 'outside-press'})) + expect(popup.matches(':popover-open')).toBe(true) + rerender() + expect(popup.matches(':popover-open')).toBe(false) + expect(onOpenChange).toHaveBeenCalledTimes(4) + }) + + it('keeps an explicit popup ID on the bubble without duplicating keybinding labels', () => { + const {container} = render( + } + />, + ) + const button = page.getByRole('button', {name: 'Run (control k)', exact: true}).element() + const popup = container.querySelector('[data-component="Tooltip"]') + expect(popup).toHaveAttribute('id', 'run-tooltip') + expect(button.getAttribute('aria-labelledby')).not.toBe('run-tooltip') + const label = document.getElementById(button.getAttribute('aria-labelledby') ?? '') + expect(label).toHaveTextContent('Run') + expect(popup).toContainElement(label) + }) + + it('opens on keyboard focus only when the owner accepts the request', async () => { + const onOpenChange = vi.fn() + const {getByText} = render( + Action} />, + ) + await userEvent.tab() + expect(onOpenChange).toHaveBeenLastCalledWith(true, expect.objectContaining({reason: 'trigger-focus'})) + expect(getByText('Details').matches(':popover-open')).toBe(false) + }) + + it('keeps the popup open when moving between the trigger and popup', () => { + const onOpenChange = vi.fn() + const {getByText} = render( + Action} />, + ) + const button = page.getByRole('button', {name: 'Action', exact: true}).element() + const popup = getByText('Details') + fireEvent.mouseOut(button, {relatedTarget: popup}) + expect(popup.matches(':popover-open')).toBe(true) + fireEvent.mouseOut(popup, {relatedTarget: button}) + expect(popup.matches(':popover-open')).toBe(true) + expect(onOpenChange).not.toHaveBeenCalled() + fireEvent.mouseOut(button, {relatedTarget: document.body}) + expect(popup.matches(':popover-open')).toBe(false) + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.objectContaining({reason: 'trigger-hover'})) + }) + + it('supports an uncontrolled initial value, ignores later default changes, and closes with Escape', async () => { + const onOpenChange = vi.fn() + const {getByText, rerender} = render( + Action} />, + ) + const popup = getByText('Details') + expect(popup.matches(':popover-open')).toBe(true) + expect(onOpenChange).not.toHaveBeenCalled() + rerender( + Action} />, + ) + expect(popup.matches(':popover-open')).toBe(true) + await userEvent.keyboard('{Escape}') + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.objectContaining({reason: 'escape-key'})) + expect(popup.matches(':popover-open')).toBe(false) + }) + + it('synchronizes native light dismissal and can reopen on a fresh hover', async () => { + const onOpenChange = vi.fn() + const {getByText} = render( + Action} />, + ) + const popup = getByText('Details') + act(() => popup.hidePopover()) + await expect.poll(() => onOpenChange.mock.calls.length).toBe(1) + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.objectContaining({reason: 'none'})) + await userEvent.hover(page.getByRole('button', {name: 'Action', exact: true})) + await expect.poll(() => popup.matches(':popover-open')).toBe(true) + expect(onOpenChange).toHaveBeenLastCalledWith(true, expect.objectContaining({reason: 'trigger-hover'})) + }) + + it.each(['label', 'description'] as const)('suppression overrides open while retaining the %s', type => { + vi.useFakeTimers() + const onOpenChange = vi.fn() + const props = { + text: 'Details', + type, + open: true, + onOpenChange, + renderAnchor: , + } + const {getByText, rerender} = render() + const popup = getByText('Details') + const button = page.getByRole('button', {name: type === 'label' ? 'Details' : 'Action', exact: true}).element() + expect(popup.matches(':popover-open')).toBe(true) + rerender() + expect(popup.matches(':popover-open')).toBe(false) + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.objectContaining({reason: 'disabled'})) + onOpenChange.mockClear() + fireEvent.mouseOver(button) + act(() => vi.advanceTimersByTime(1200)) + expect(onOpenChange).not.toHaveBeenCalled() + expect(button).toBeEnabled() + if (type === 'label') { + expect(button).toHaveAccessibleName('Details') + } else { + expect(button).toHaveAccessibleDescription('Details') + } + rerender() + expect(popup.matches(':popover-open')).toBe(true) + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it.each(['children', 'renderAnchor'] as const)( + 'keeps suppression separate from trigger disabled in %s mode', + async mode => { + const ref = createRef() + const onClick = vi.fn() + const onOpenChange = vi.fn() + const trigger = ( + + ) + const props = { + text: 'Details', + defaultOpen: true, + onOpenChange, + ...(mode === 'renderAnchor' ? {renderAnchor: trigger} : {children: trigger}), + } + const {getByText, rerender} = render() + const popup = getByText('Details') + const button = page.getByRole('button', {name: 'Action', exact: true}) + for (const suppression of [ + {disabled: true, _privateDisableTooltip: false}, + {disabled: false, _privateDisableTooltip: true}, + {disabled: true, _privateDisableTooltip: true}, + ]) { + rerender() + expect(popup.matches(':popover-open')).toBe(false) + expect(button.element()).toBe(ref.current) + expect(button.element()).toBeEnabled() + expect(button.element()).toHaveAccessibleDescription('Details') + await userEvent.click(button) + } + expect(onClick).toHaveBeenCalledTimes(3) + expect(onOpenChange.mock.calls.some(([open]) => open)).toBe(false) + + vi.useFakeTimers() + rerender(Action} />) + expect(button.element()).toBeDisabled() + fireEvent.mouseOver(button.element()) + act(() => vi.advanceTimersByTime(50)) + expect(popup.matches(':popover-open')).toBe(false) + }, + ) + + it('cancels a pending open on suppression and requires a fresh interaction when re-enabled', () => { + vi.useFakeTimers() + const onOpenChange = vi.fn() + const props = {text: 'Details', onOpenChange, renderAnchor: , delay: 'medium' as const} + const {getByText, rerender} = render() + const popup = getByText('Details') + const button = page.getByRole('button', {name: 'Action', exact: true}).element() + fireEvent.mouseOver(button) + act(() => vi.advanceTimersByTime(399)) + rerender() + act(() => vi.advanceTimersByTime(1)) + expect(onOpenChange).not.toHaveBeenCalled() + rerender() + act(() => vi.advanceTimersByTime(1200)) + expect(popup.matches(':popover-open')).toBe(false) + fireEvent.mouseOver(button) + act(() => vi.advanceTimersByTime(400)) + expect(popup.matches(':popover-open')).toBe(true) + expect(onOpenChange).toHaveBeenCalledOnce() + }) + + it('cancels delayed requests on pointer leave and unmount', () => { + vi.useFakeTimers() + const onOpenChange = vi.fn() + const {unmount} = render( + Action} />, + ) + const button = page.getByRole('button', {name: 'Action', exact: true}).element() + fireEvent.mouseOver(button) + fireEvent.mouseLeave(button) + act(() => vi.advanceTimersByTime(50)) + expect(onOpenChange).not.toHaveBeenCalled() + fireEvent.mouseOver(button) + unmount() + act(() => vi.advanceTimersByTime(50)) + expect(onOpenChange).not.toHaveBeenCalled() + }) +}) + +describe('experimental element renderAnchor', () => { + it.runIf(reactMajorVersion >= 19)('preserves React 19 cleanup functions through every composed ref', () => { + const cleanupTooltip = vi.fn() + const cleanupButton = vi.fn() + const tooltipRef = vi.fn((node: HTMLElement | null) => (node ? cleanupTooltip : undefined)) + const buttonRef = vi.fn((node: HTMLButtonElement | null) => (node ? cleanupButton : undefined)) + const {unmount} = render( + Owner} /> + } + > + Contents + , + ) + expect(tooltipRef).toHaveBeenLastCalledWith(page.getByRole('button', {name: 'Owner', exact: true}).element()) + unmount() + expect(cleanupTooltip).toHaveBeenCalledOnce() + expect(cleanupButton).toHaveBeenCalledOnce() + expect(tooltipRef).not.toHaveBeenCalledWith(null) + expect(buttonRef).not.toHaveBeenCalledWith(null) + }) + + it.each([false, true])('composes one menu button, all refs, and element props (merged-ref flag: %s)', async flag => { + const anchorRef = createRef() + const tooltipRef = vi.fn() + const buttonRef = createRef() + const calls: string[] = [] + const parentClick = vi.fn() + function Example() { + const [open, setOpen] = useState(false) + return ( +
{}}> + { + calls.push('menu') + setOpen(value) + }} + renderAnchor={ + { + calls.push('element') + event.stopPropagation() + }} + > + Owner + + } + /> + } + > + + + Assign me + + + +
+ ) + } + const {unmount} = render( + + + , + ) + const button = page.getByRole('button', {name: 'Owner', exact: true}) + const element = button.element() + expect(document.querySelectorAll('button')).toHaveLength(1) + expect(anchorRef.current).toBe(element) + expect(buttonRef.current).toBe(element) + expect(tooltipRef).toHaveBeenLastCalledWith(element) + expect(element).toHaveAttribute('id', 'owner-trigger') + expect(element).toHaveClass('inner', 'outer') + expect(element.className.indexOf('inner')).toBeLessThan(element.className.indexOf('outer')) + expect(element).toHaveStyle({color: 'rgb(0, 0, 255)', padding: '4px'}) + expect(element).toHaveAccessibleDescription('Choose owner') + + await userEvent.click(button) + expect(calls).toEqual(['element', 'menu']) + expect(parentClick).not.toHaveBeenCalled() + await expect.element(button).toHaveAttribute('aria-expanded', 'true') + await expect.element(page.getByRole('menu', {name: 'Owner', exact: true})).toBeVisible() + await expect.element(page.getByRole('menuitem', {name: 'Assign me', exact: true})).toHaveFocus() + await userEvent.keyboard('{Escape}') + await expect.element(button).toHaveAttribute('aria-expanded', 'false') + await expect.element(button).toHaveFocus() + await userEvent.keyboard('{ArrowDown}') + await expect.element(page.getByRole('menuitem', {name: 'Assign me', exact: true})).toHaveFocus() + + unmount() + expect(anchorRef.current).toBeNull() + expect(buttonRef.current).toBeNull() + expect(tooltipRef).toHaveBeenLastCalledWith(null) + }) + + it('lets the element prevent both pointer and keyboard menu activation without chaining handlers', async () => { + const onOpenChange = vi.fn() + render( + event.preventDefault()} onKeyDown={event => event.preventDefault()}> + Owner + + } + /> + } + > + Menu contents + , + ) + const button = page.getByRole('button', {name: 'Owner', exact: true}) + await userEvent.click(button) + await userEvent.keyboard('{ArrowDown}') + expect(onOpenChange).not.toHaveBeenCalled() + expect(button.element()).toHaveAttribute('aria-expanded', 'false') + }) + + it('merges and deduplicates inherited label and description ID lists', () => { + const {getByText} = render( + <> + Owner + Shared + Outer + Inner + + Fallback name + + } + /> + } + > + Menu contents + + , + ) + const button = page.getByRole('button', {name: 'Owner', exact: true}).element() + const tooltip = getByText('Choose owner') + expect(button).toHaveAttribute('aria-labelledby', 'label') + const ids = button.getAttribute('aria-describedby')?.split(' ') ?? [] + expect(ids).toEqual(['inner-description', 'shared', 'outer-description', tooltip.id]) + expect(button).toHaveAccessibleDescription('Inner Shared Outer Choose owner') + expect(new Set(ids).size).toBe(ids.length) + }) + + it('preserves descriptions when the tooltip supplies the accessible name', () => { + const {getByText} = render( + <> + Existing description + } + /> + , + ) + const button = page.getByRole('button', {name: 'Information', exact: true}).element() + expect(button).toHaveAttribute('aria-labelledby', getByText('Information').id) + expect(button).toHaveAccessibleDescription('Existing description') + }) + + it.each([false, true])('does not activate a disabled trigger (native: %s)', native => { + const onOpenChange = vi.fn() + render( + + Owner + + } + /> + } + > + Menu contents + , + ) + const button = page.getByRole('button', {name: 'Owner', exact: true}).element() + fireEvent.click(button) + fireEvent.keyDown(button, {key: 'Enter'}) + fireEvent.keyDown(button, {key: 'ArrowDown'}) + expect(onOpenChange).not.toHaveBeenCalled() + expect(button).toHaveAttribute('aria-expanded', 'false') + const tab = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true, cancelable: true}) + expect(button.dispatchEvent(tab)).toBe(true) + }) + + it('composes a direct overlay with an icon button, description, and focus return', async () => { + const anchorRef = createRef() + const buttonRef = createRef() + function Example() { + const [open, setOpen] = useState(false) + return ( + setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={ + } + /> + } + > + + + ) + } + const {unmount} = render() + const button = page.getByRole('button', {name: 'View message info', exact: true}) + expect(document.querySelectorAll('button')).toHaveLength(1) + expect(button.element()).toHaveAccessibleDescription('Message details') + expect(anchorRef.current).toBe(button.element()) + expect(buttonRef.current).toBe(button.element()) + act(() => button.element().focus()) + await userEvent.keyboard('{ArrowDown}') + await expect.element(page.getByRole('button', {name: 'Close details', exact: true})).toHaveFocus() + await userEvent.keyboard('{Escape}') + await expect.element(button).toHaveAttribute('aria-expanded', 'false') + await expect.element(button).toHaveFocus() + unmount() + expect(anchorRef.current).toBeNull() + expect(buttonRef.current).toBeNull() + }) + + it('preserves the original overlay callback object ref and single argument', () => { + const anchorRef = createRef() + const renderAnchor = vi.fn((props: React.HTMLAttributes) => ( + + )) + render( + + Contents + , + ) + expect(renderAnchor.mock.calls[0]).toHaveLength(1) + expect(renderAnchor.mock.calls[0][0]).toHaveProperty('ref', anchorRef) + expect(anchorRef.current).toBe(page.getByRole('button', {name: 'Owner', exact: true}).element()) + }) + + it('preserves configured anchors and legacy Tooltip slot recognition', async () => { + render( + + + Owner + + + + Assign me + + + , + ) + await userEvent.click(page.getByRole('button', {name: 'Owner', exact: true})) + await expect.element(page.getByRole('menuitem', {name: 'Assign me', exact: true})).toHaveFocus() + }) + + it.each([false, true])('rejects a configured anchor together with renderAnchor (tooltip: %s)', withTooltip => { + withExpectedConsoleError(() => { + expect(() => + render( + Owner}> + {withTooltip ? ( + + Another anchor + + ) : ( + + + + )} + Contents + , + ), + ).toThrow('Pass either `renderAnchor` or `ActionMenu.Button`/`ActionMenu.Anchor`, not both.') + }) + }) +}) diff --git a/packages/react/src/experimental/TriggerComposition/TriggerComposition.tsx b/packages/react/src/experimental/TriggerComposition/TriggerComposition.tsx new file mode 100644 index 00000000000..ed081940f9d --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/TriggerComposition.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import {ActionMenu as StableActionMenu, ActionMenuImpl} from '../../ActionMenu/ActionMenu' +import type {ActionMenuProps as StableActionMenuProps} from '../../ActionMenu/ActionMenu' +import {AnchoredOverlay as StableAnchoredOverlay} from '../../AnchoredOverlay' +import type {AnchoredOverlayProps as StableAnchoredOverlayProps} from '../../AnchoredOverlay' +import {Tooltip as StableTooltip, TooltipImpl} from '../../TooltipV2/Tooltip' +import type {TooltipProps as StableTooltipProps, TooltipOpenChangeDetails} from '../../TooltipV2/Tooltip' +import {useControllableState} from '../../hooks/useControllableState' +import {useProvidedRefOrCreate} from '../../hooks/useProvidedRefOrCreate' +import {RenderAnchor, type AnchorRenderer} from '../../internal/components/RenderAnchor' +import {mergeAnchorProps, type AnchorProps} from '../../internal/utils/mergeAnchorProps' +import {invariant} from '../../utils/invariant' +import {asSlot} from '../../utils/as-slot' +import type {SlotMarker} from '../../utils/types' + +export type ActionMenuProps = StableActionMenuProps & {renderAnchor?: AnchorRenderer} + +function Menu({renderAnchor, ...props}: ActionMenuProps) { + return ( + : undefined + } + /> + ) +} + +export const ActionMenu = Object.assign(Menu, { + Button: StableActionMenu.Button, + Anchor: StableActionMenu.Anchor, + Overlay: StableActionMenu.Overlay, + Divider: StableActionMenu.Divider, +}) + +export type AnchoredOverlayProps = + | StableAnchoredOverlayProps + | (Omit & {renderAnchor: React.ReactElement}) + +export function AnchoredOverlay(props: React.PropsWithChildren) { + const anchorRef = useProvidedRefOrCreate(props.anchorRef) + if (props.renderAnchor === null) { + return + } + if (typeof props.renderAnchor === 'function') { + return + } + const {renderAnchor} = props + return ( + } + /> + ) +} + +export type TooltipProps = Omit & { + /** Suppresses the tooltip, not its trigger. Either suppression prop being true wins. */ + disabled?: boolean + renderAnchor?: AnchorRenderer + /** HTML props for the tooltip bubble, separate from the trigger's HTML props. */ + popupProps?: Omit, 'children'> & {popover?: 'auto' | 'manual' | ''} + open?: boolean + defaultOpen?: boolean + onOpenChange?: (open: boolean, details: TooltipOpenChangeDetails) => void +} + +export type {TooltipOpenChangeDetails} + +export const Tooltip: React.ForwardRefExoticComponent< + React.PropsWithoutRef & React.RefAttributes +> & + SlotMarker = React.forwardRef( + ( + { + renderAnchor, + children, + text, + type, + direction, + delay, + keybindingHint, + disabled = false, + _privateDisableTooltip, + popupProps, + open: controlledOpen, + defaultOpen = false, + onOpenChange, + ...anchorProps + }, + ref, + ) => { + invariant(!renderAnchor || children == null, 'Pass either `children` or `renderAnchor` to `Tooltip`, not both.') + const [open, setOpen] = useControllableState({ + name: 'Tooltip', + value: controlledOpen, + defaultValue: defaultOpen, + }) + const [controlled] = React.useState(controlledOpen !== undefined) + const visibility = React.useMemo( + () => ({ + open, + controlled, + onOpenChange(next: boolean, details: TooltipOpenChangeDetails) { + if (open === next) return + setOpen(next) + onOpenChange?.(next, details) + }, + }), + [open, controlled, setOpen, onOpenChange], + ) + const trigger = renderAnchor ?? React.Children.only(children) + if (!React.isValidElement(trigger) && typeof trigger !== 'function') { + throw new Error('Tooltip requires a single trigger.') + } + return ( + ( + + )} + /> + ) + }, +) +Tooltip.displayName = 'Tooltip' +asSlot(Tooltip, StableTooltip) diff --git a/packages/react/src/experimental/TriggerComposition/TriggerComposition.types.test.tsx b/packages/react/src/experimental/TriggerComposition/TriggerComposition.types.test.tsx new file mode 100644 index 00000000000..675bd48f559 --- /dev/null +++ b/packages/react/src/experimental/TriggerComposition/TriggerComposition.types.test.tsx @@ -0,0 +1,60 @@ +import type React from 'react' +import {InfoIcon} from '@primer/octicons-react' +import {ActionMenu, AnchoredOverlay, Tooltip} from './TriggerComposition' +import {ActionMenu as StableActionMenu} from '../../ActionMenu' +import {Tooltip as StableTooltip} from '../../TooltipV2' +import {Button, IconButton} from '../../Button' + +export function elementComposition() { + return ( + Owner} />}> + Contents + + ) +} + +export function directOverlayComposition() { + return ( + } /> + } + > + Contents + + ) +} + +export function stableMenuContract() { + // @ts-expect-error renderAnchor is an experimental ActionMenu API + return Owner}>Contents +} + +export function stableTooltipContract() { + // @ts-expect-error renderAnchor is an experimental Tooltip API + return Owner} /> +} + +export function experimentalTooltipSuppressionContract() { + return Owner} /> +} + +export function experimentalTooltipControlContract() { + return ( + { + const next: boolean = open + const cause: string = reason + const source: Event | React.SyntheticEvent | undefined = event + return {next, cause, source} + }} + popupProps={{id: 'tooltip', className: 'tooltip-bubble', popover: 'manual'}} + id="trigger" + text="Details" + renderAnchor={} + /> + ) +} diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts index 76298f3e514..2f137955620 100644 --- a/packages/react/src/experimental/index.ts +++ b/packages/react/src/experimental/index.ts @@ -83,8 +83,13 @@ export type { NavListDividerProps, } from '../NavList' export * from './SelectPanel2' -export {Tooltip} from '../TooltipV2' -export type {TooltipProps} from '../TooltipV2' +export {Tooltip, ActionMenu, AnchoredOverlay} from './TriggerComposition/TriggerComposition' +export type { + TooltipProps, + TooltipOpenChangeDetails, + ActionMenuProps, + AnchoredOverlayProps, +} from './TriggerComposition/TriggerComposition' export * from '../ActionBar' export {ScrollableRegion} from '../ScrollableRegion' diff --git a/packages/react/src/internal/components/RenderAnchor.tsx b/packages/react/src/internal/components/RenderAnchor.tsx new file mode 100644 index 00000000000..72e1e9016c0 --- /dev/null +++ b/packages/react/src/internal/components/RenderAnchor.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {useMergedRefs} from '../../hooks/useMergedRefs' +import {reactMajorVersion} from '../../utils/environment' +import {invariant} from '../../utils/invariant' +import {mergeAnchorProps, type AnchorProps} from '../utils/mergeAnchorProps' + +export type AnchorRenderProps = React.HTMLAttributes & {ref: React.RefCallback} +export type AnchorRenderer = React.ReactElement | ((props: AnchorRenderProps) => React.ReactElement) + +export function RenderAnchor({render, props}: {render: AnchorRenderer; props: AnchorProps}) { + const element = typeof render === 'function' ? undefined : render + invariant( + typeof render === 'function' || (React.isValidElement(element) && element.type !== React.Fragment), + '`renderAnchor` must render a single interactive element, not a React Fragment.', + ) + const elementRef = + reactMajorVersion > 18 ? element?.props.ref : (element as {ref?: AnchorProps['ref']} | undefined)?.ref + const ref = useMergedRefs(props.ref, elementRef) + if (typeof render === 'function') return render({...props, ref}) + + const merged = mergeAnchorProps(props, render.props) + return React.cloneElement(render, { + ...merged, + onClick: preventDisabledActivation(merged.onClick), + onKeyDown: preventDisabledActivation(merged.onKeyDown), + ref, + }) +} + +function preventDisabledActivation>(handler?: (event: E) => void) { + return (event: E) => { + const isActivation = !('key' in event) || ['Enter', ' ', 'ArrowDown', 'ArrowUp'].some(key => event.key === key) + if ( + isActivation && + (event.currentTarget.matches(':disabled') || event.currentTarget.getAttribute('aria-disabled') === 'true') + ) { + event.preventDefault() + return + } + handler?.(event) + } +} diff --git a/packages/react/src/internal/utils/mergeAnchorProps.ts b/packages/react/src/internal/utils/mergeAnchorProps.ts new file mode 100644 index 00000000000..f57c5723585 --- /dev/null +++ b/packages/react/src/internal/utils/mergeAnchorProps.ts @@ -0,0 +1,22 @@ +import type React from 'react' +import {clsx} from 'clsx' +import {mergeProps} from '../../utils/mergeProps' + +export type AnchorProps = React.HTMLAttributes & { + ref?: React.RefCallback | {readonly current: HTMLElement | null} | null +} + +export function mergeAnchorProps(props: AnchorProps, elementProps: AnchorProps): AnchorProps { + const labelledBy = mergeIds(elementProps['aria-labelledby'], props['aria-labelledby']) + const describedBy = mergeIds(elementProps['aria-describedby'], props['aria-describedby']) + return { + ...mergeProps(props, elementProps, {eventHandlerOrder: 'consumer-first'}), + className: clsx(elementProps.className, props.className) || undefined, + ...(labelledBy ? {'aria-labelledby': labelledBy} : {}), + ...(describedBy ? {'aria-describedby': describedBy} : {}), + } +} + +function mergeIds(first?: string, second?: string) { + return [...new Set(`${first ?? ''} ${second ?? ''}`.split(/\s+/).filter(Boolean))].join(' ') || undefined +} diff --git a/packages/react/src/utils/mergeProps.ts b/packages/react/src/utils/mergeProps.ts index a7d6df5de9b..df18b5c55d0 100644 --- a/packages/react/src/utils/mergeProps.ts +++ b/packages/react/src/utils/mergeProps.ts @@ -13,7 +13,11 @@ type ClassValue = Parameters[number] * * In other cases, if a key exists in both `A` and `B` then the value in `B` will be used in the final result */ -function mergeProps(a: A, b: B): Merge { +function mergeProps( + a: A, + b: B, + options?: {eventHandlerOrder: 'consumer-first'}, +): Merge { const merged = {...a} as Record for (const [key, value] of Object.entries(b)) { @@ -24,7 +28,10 @@ function mergeProps(a: A, b: B): Merge