diff --git a/docs/content/react/components/help-bubble.mdx b/docs/content/react/components/help-bubble.mdx index b259ff51d3..3f5738a440 100644 --- a/docs/content/react/components/help-bubble.mdx +++ b/docs/content/react/components/help-bubble.mdx @@ -61,6 +61,21 @@ npx @seed-design/cli@latest add ui:help-bubble ``` +### Hover Trigger + +`trigger="hover"`를 설정하면 클릭 대신 포인터 호버로 Help Bubble이 열리고 닫힙니다. `openDelay`와 `closeDelay`로 열림/닫힘 지연(ms)을 조절할 수 있습니다. + +터치 디바이스에서도 동작하며, 디바이스 환경에 따라 동작이 자동 전환되지는 않습니다. 필요에 따라 명시적으로 `trigger` 값을 지정해야 합니다. + + + ```json doc-gen:file + { + "file": "examples/react/help-bubble/hover.tsx", + "codeblock": true + } + ``` + + ### Anchor `HelpBubbleAnchor`의 `children`은 Help Bubble이 위치를 잡는 데에만 사용되며 클릭으로 열고 닫는 동작은 없습니다. diff --git a/docs/examples/react/help-bubble/hover.tsx b/docs/examples/react/help-bubble/hover.tsx new file mode 100644 index 0000000000..ed9445c7a1 --- /dev/null +++ b/docs/examples/react/help-bubble/hover.tsx @@ -0,0 +1,19 @@ +import { IconILowercaseSerifCircleLine } from "@karrotmarket/react-monochrome-icon"; +import { Icon } from "@seed-design/react"; +import { HelpBubbleTrigger } from "seed-design/ui/help-bubble"; +import { ActionButton } from "seed-design/ui/action-button"; + +export default function HelpBubbleHover() { + return ( + + + } /> + + + ); +} diff --git a/packages/react-headless/popover/src/usePopover.ts b/packages/react-headless/popover/src/usePopover.ts index 58dcdf9a15..aeafaf5ad6 100644 --- a/packages/react-headless/popover/src/usePopover.ts +++ b/packages/react-headless/popover/src/usePopover.ts @@ -1,6 +1,7 @@ import { useClick, useDismiss, + useHover, useInteractions, useRole, useTransitionStatus, @@ -9,19 +10,55 @@ import { buttonProps, dataAttr, elementProps } from "@seed-design/dom-utils"; import { useMemo } from "react"; import { usePositionedFloating, type UsePositionedFloatingProps } from "./floating"; -// TODO: useRole이 임의로 id를 생성하는 문제가 있음. 동작만 참고하고 role="dialog"에 맞게 aria attribute 설정을 직접 해야 함. - export interface UsePopoverProps extends UsePositionedFloatingProps { /** * Whether to close the popover when clicking outside of it. * @default true */ closeOnInteractOutside?: boolean; + /** + * How the popover opens. `"hover"` uses pointer enter/leave (touch included); + * dismiss behavior (outside press, escape) still applies in both modes. + * @default "click" + */ + trigger?: "click" | "hover"; + /** + * Delay in milliseconds before the popover opens on hover. + * Only applies when `trigger` is `"hover"`. + * @default 0 + */ + openDelay?: number; + /** + * Delay in milliseconds before the popover closes on hover leave. + * Only applies when `trigger` is `"hover"`. + * @default 0 + */ + closeDelay?: number; + /** + * ARIA role of the floating element, which also controls the aria + * attributes emitted on the trigger. + * + * - `"dialog"`: trigger gets `aria-haspopup="dialog"` and `aria-expanded`; + * the floating element gets `role="dialog"`. + * - `"tooltip"`: trigger gets `aria-describedby` while open; the floating + * element gets `role="tooltip"`. Per WAI-ARIA, tooltip content must not + * contain focusable children. + * + * @default "dialog" + */ + role?: "dialog" | "tooltip"; } export type UsePopoverReturn = ReturnType; -export function usePopover({ closeOnInteractOutside, ...props }: UsePopoverProps = {}) { +export function usePopover({ + closeOnInteractOutside, + trigger = "click", + openDelay, + closeDelay, + role: roleProp, + ...props +}: UsePopoverProps = {}) { const { open, onOpenChange, @@ -35,14 +72,18 @@ export function usePopover({ closeOnInteractOutside, ...props }: UsePopoverProps rects, } = usePositionedFloating(props); - const role = useRole(context); - const click = useClick(context); + const role = useRole(context, { role: roleProp ?? "dialog" }); + const click = useClick(context, { enabled: trigger === "click" }); + const hover = useHover(context, { + enabled: trigger === "hover", + delay: { open: openDelay, close: closeDelay }, + }); const dismiss = useDismiss(context, { outsidePress: closeOnInteractOutside ?? true, }); const { status } = useTransitionStatus(context); - const triggerInteractions = useInteractions([role, click, dismiss]); + const triggerInteractions = useInteractions([role, click, hover, dismiss]); const anchorInteractions = useInteractions([role, dismiss]); const stateProps = useMemo( @@ -71,8 +112,6 @@ export function usePopover({ closeOnInteractOutside, ...props }: UsePopoverProps stateProps, anchorProps: elementProps({ ...anchorInteractions.getReferenceProps(), ...stateProps }), triggerProps: elementProps({ - "aria-haspopup": "dialog", - "aria-expanded": open, ...triggerInteractions.getReferenceProps(), ...stateProps, }),