Hooks
The ten hooks that ship with the components — outside-press dismissal, viewport-aware panel placement, focus trapping, scroll locking.
Ten hooks ship alongside the components. They are the parts of a component's behaviour that a host regularly needs on its own — a bespoke dropdown still has to dismiss on an outside press, a bespoke drawer still has to trap focus.
Import
1import {2 useEscapeKey,3 useDismissOnOutside,4 useAnchoredPosition,5 useFocusTrap,6 useBodyScrollLock,7 useModalBehavior,8 useScrollableTabStop,9} from "@yuhuanowo/yunui";The adapter
useYunUI()
Everything a component must not hard-code: Link, Image, useRouter, useT
and iconBasePath, as supplied by whatever YunUIProvider the app mounted.
1const { Link, Image, useT } = useYunUI();2const t = useT("myNamespace");This is why the library binds no router and no i18n library: a component asks the host for those, and the host decides whether they come from Next, from React Router, or from nothing at all (the provider ships sane fallbacks, so a component still renders in a plain Vite app). See Getting Started for wiring it up.
Overlays
useDismissOnOutside(open, onDismiss, ref, options?)
Closes a floating panel when the pointer goes down outside it, and on Escape
unless you turn that off with { escape: false }.
Six components in this library hand-rolled this and no two agreed: four
registered the document listener permanently — it ran on every click in the host
app whether the panel was open or not — and four listened for mousedown only,
so tapping away on a phone did not reliably close them. This is the union of the
correct halves: listeners exist only while open, and cover mouse and touch.
const ref = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
useDismissOnOutside(open, () => setOpen(false), ref);
Pass { extraRefs: [panelRef] } when the panel is portalled, so its DOM is not
a descendant of the trigger and would otherwise count as "outside".
useAnchoredPosition(open, panelRef, options?)
Viewport collision for a hand-rolled floating panel. Radix-based ones
(Select, Popover, DropdownMenu) flip and shift by themselves; anything you
position with absolute does not, and runs off the bottom of a phone.
Returns { shift, maxHeight, placement } — a horizontal offset, a height cap,
and "top" / "bottom" — keeping the panel inside the viewport and inside any
scrolling or clipping ancestor. It measures with untransformed offset* values,
so a scale/translate animation in flight does not skew the result.
1const { shift, maxHeight, placement } = useAnchoredPosition(open, panelRef);2<div ref={panelRef} style={{ marginLeft: shift, maxHeight }}3 className={placement === "top" ? "bottom-full mb-2" : "top-full mt-2"} />useEscapeKey(onEscape, enabled?)
Escape alone. A document-level listener on purpose: WebKit does not focus a
<button> on click, so a container-scoped onKeyDown never receives the key
after a mouse user opens the panel.
useModalBehavior(isOpen, onClose)
useEscapeKey + useBodyScrollLock together — the pair every modal needs.
useBodyScrollLock(locked?)
Stops the page behind an overlay from scrolling, and restores the previous
overflow on unmount rather than assuming it was visible.
useFocusTrap(ref, active)
Cycles Tab within the container while active, and restores focus to
whatever had it when the trap opened.
Layout
useScrollableTabStop(ref, axis?)
Returns 0 while the element actually overflows and undefined when it does
not, so you can make a scroll container focusable only when there is something
to scroll.
Chrome quietly makes overflowing scroll containers focusable; Safari and Firefox
do not — so a wide table of plain cells (no links, no buttons) has no keyboard
route at all to the columns past the right edge. The usual fix is a permanent
tabIndex={0}, which then adds a dead tab stop to every table that fits. This
measures instead, re-checking on resize and on content changes.
1const ref = useRef<HTMLDivElement>(null);2const tabIndex = useScrollableTabStop(ref);3<div ref={ref} tabIndex={tabIndex} className="overflow-x-auto">…</div>Table already uses it. Reach for it on any other overflow-* wrapper you own.