Files
heygen-com__hyperframes/packages/studio/src/components/ui/useDialogBehavior.ts
Vance Ingalls cd1adcb581 feat(studio): harden ui primitives — focus rings, keyboard tooltips, dialog hook (#1962)
## Summary

Base of the studio UX-review stack (148 findings audited across the studio; 13 critical). This PR hardens the shared `components/ui` primitives that every later PR in the stack builds on.

## Changes

- **Button / IconButton**: visible `focus-visible` outline (studio accent); `disabled:pointer-events-none` removed (replaced with `disabled:cursor-not-allowed`, hover/active gated behind `enabled:`) so disabled buttons can host explain-why tooltips.
- **Tooltip**: keyboard support (`onFocus`/`onBlur` triggers), `role="tooltip"`, Escape-to-hide, viewport flip (top↔bottom) + horizontal clamping. API unchanged — all ~28 call sites unaffected.
- **HyperframesLoader**: `role="status"` on the loader; determinate track is a real `role="progressbar"` with `aria-valuenow/min/max` (was `aria-hidden`).
- **VideoFrameThumbnail**: error event resolves to a static fallback-label tile instead of an infinite shimmer; `motion-reduce` guard.
- **NEW `useDialogBehavior`**: shared modal contract — document-level Escape, Tab focus trap, focus-first-on-open, focus-restore-on-close, `canClose()` veto for dirty-draft guards. Adopted by every modal later in the stack.
- **NEW `SearchInput`**: shared search primitive with required `aria-label`, panel-input token style (kills the two-divergent-search-styles inconsistency in the sidebar).
- **studio.css**: `hf-toast-in/out` + `hf-backdrop-in` keyframes with `prefers-reduced-motion` guards (the previous `animate-in fade-in` classes were dead — no tailwindcss-animate plugin exists).

## Verification

- oxlint 0 errors, oxfmt clean, `tsc --noEmit` clean at stack top
- Full studio suite at stack top: 1189 tests pass

## Stack

PR 1/7 of the studio UX-review fixes. Merges bottom-up; the stack top is fully green (tsc + 1189 tests). Some shared-file edits span PRs, so intermediate branches may not typecheck in isolation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-06 16:07:44 -07:00

84 lines
2.8 KiB
TypeScript

// fallow-ignore-file unused-file
// (consumers land in the shell/sidebar PRs later in this stack)
import { useEffect, useCallback, useRef, type RefObject } from "react";
const FOCUSABLE =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
interface DialogBehaviorOptions {
open: boolean;
onClose: () => void;
containerRef: RefObject<HTMLElement | null>;
/**
* Return false to veto a close triggered by Escape/backdrop (e.g. a dirty
* draft the user hasn't submitted). Direct onClose calls are not guarded.
*/
canClose?: () => boolean;
}
/**
* Shared dialog contract for the studio's custom modals: document-level
* Escape, Tab focus trap, focus-first-control on open, focus restore on close.
* The consumer still renders its own markup and should set role="dialog" and
* aria-modal="true" on the container.
*/
export function useDialogBehavior({
open,
onClose,
containerRef,
canClose,
}: DialogBehaviorOptions) {
const restoreRef = useRef<HTMLElement | null>(null);
const canCloseRef = useRef(canClose);
canCloseRef.current = canClose;
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const requestClose = useCallback(() => {
const guard = canCloseRef.current;
if (guard && !guard()) return;
onCloseRef.current();
}, []);
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement;
restoreRef.current = previouslyFocused instanceof HTMLElement ? previouslyFocused : null;
const container = containerRef.current;
const first = container?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? container)?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
requestClose();
return;
}
if (e.key !== "Tab") return;
const el = containerRef.current;
if (!el) return;
const focusables = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusables.length === 0) return;
const firstEl = focusables[0];
const lastEl = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === firstEl || !el.contains(active))) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && (active === lastEl || !el.contains(active))) {
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
restoreRef.current?.focus();
restoreRef.current = null;
};
}, [open, containerRef, requestClose]);
return { requestClose };
}