1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 22:34:25 -04:00

Add HybridTooltip component and usePointerCapability hook for adaptive tooltip/popover behavior

This commit is contained in:
2025-10-08 10:17:25 -04:00
parent a63d17a1a1
commit 04b50f9d2a
2 changed files with 145 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
"use client";
import type * as React from "react";
import { createContext, useContext, useMemo } from "react";
import {
Popover as UiPopover,
PopoverContent as UiPopoverContent,
PopoverTrigger as UiPopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip as UiTooltip,
TooltipContent as UiTooltipContent,
TooltipProvider as UiTooltipProvider,
TooltipTrigger as UiTooltipTrigger,
} from "@/components/ui/tooltip";
import { usePreferPopoverForTooltip } from "@/hooks/use-pointer-capability";
type Variant = "tooltip" | "popover";
const VariantContext = createContext<Variant>("tooltip");
export type HybridTooltipProps =
| ({ forceVariant?: Variant } & React.ComponentProps<typeof UiTooltip>)
| ({ forceVariant?: Variant } & React.ComponentProps<typeof UiPopover>);
/**
* HybridTooltip switches between Tooltip (desktop/hover) and Popover (touch/coarse) at runtime.
* It preserves the familiar Tooltip API while providing tap-to-open behavior on touch devices.
*
* Props mirror the shadcn Tooltip/Popover roots. Prefer controlled props when needed.
*/
export function HybridTooltip({ forceVariant, ...props }: HybridTooltipProps) {
const preferPopover = usePreferPopoverForTooltip();
// Default to tooltip for SSR/hydration safety; only switch after mount via hook.
const variant: Variant = useMemo(() => {
return forceVariant ?? (preferPopover ? "popover" : "tooltip");
}, [forceVariant, preferPopover]);
if (variant === "popover") {
return (
<VariantContext.Provider value="popover">
<UiPopover {...(props as React.ComponentProps<typeof UiPopover>)} />
</VariantContext.Provider>
);
}
return (
<VariantContext.Provider value="tooltip">
<UiTooltipProvider>
<UiTooltip {...(props as React.ComponentProps<typeof UiTooltip>)} />
</UiTooltipProvider>
</VariantContext.Provider>
);
}
export type HybridTooltipTriggerProps =
| React.ComponentProps<typeof UiTooltipTrigger>
| React.ComponentProps<typeof UiPopoverTrigger>;
export function HybridTooltipTrigger(props: HybridTooltipTriggerProps) {
const variant = useContext(VariantContext);
return variant === "popover" ? (
<UiPopoverTrigger
{...(props as React.ComponentProps<typeof UiPopoverTrigger>)}
/>
) : (
<UiTooltipTrigger
{...(props as React.ComponentProps<typeof UiTooltipTrigger>)}
/>
);
}
export type HybridTooltipContentProps =
| (React.ComponentProps<typeof UiTooltipContent> & { hideArrow?: boolean })
| React.ComponentProps<typeof UiPopoverContent>;
export function HybridTooltipContent({
hideArrow,
...props
}: HybridTooltipContentProps & { hideArrow?: boolean }) {
const variant = useContext(VariantContext);
return variant === "popover" ? (
<UiPopoverContent
{...(props as React.ComponentProps<typeof UiPopoverContent>)}
/>
) : (
<UiTooltipContent
{...(props as React.ComponentProps<typeof UiTooltipContent>)}
hideArrow={hideArrow}
/>
);
}

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
export type PointerCapability = {
supportsHover: boolean;
isCoarsePointer: boolean;
};
/**
* React hook that reports the current pointer/hover capability of the device.
*
* - supportsHover: true when the primary input can meaningfully hover (e.g., mouse)
* - isCoarsePointer: true when the primary pointer is coarse (e.g., touch)
*
* Notes:
* - Defaults to {supportsHover: false, isCoarsePointer: false} before mount to avoid SSR mismatches.
* - Listens to `(hover: hover)` and `(pointer: coarse)` media queries and updates on change.
*/
export function usePointerCapability(): PointerCapability {
const [capability, setCapability] = useState<PointerCapability>({
supportsHover: false,
isCoarsePointer: false,
});
useEffect(() => {
const hoverMql = window.matchMedia("(hover: hover)");
const coarseMql = window.matchMedia("(pointer: coarse)");
const update = () =>
setCapability({
supportsHover: hoverMql.matches,
isCoarsePointer: coarseMql.matches,
});
update();
hoverMql.addEventListener("change", update);
coarseMql.addEventListener("change", update);
return () => {
hoverMql.removeEventListener("change", update);
coarseMql.removeEventListener("change", update);
};
}, []);
return capability;
}
/**
* Returns true when we should prefer a Popover to emulate tooltip behavior on touch/coarse devices.
* Current heuristic: prefer popover when there is no hover support or the pointer is coarse.
*/
export function usePreferPopoverForTooltip(): boolean {
const { supportsHover, isCoarsePointer } = usePointerCapability();
return !supportsHover || isCoarsePointer;
}