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:
92
components/hybrid-tooltip.tsx
Normal file
92
components/hybrid-tooltip.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
53
hooks/use-pointer-capability.ts
Normal file
53
hooks/use-pointer-capability.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user