mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
cd1adcb581
## 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)
84 lines
2.8 KiB
TypeScript
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 };
|
|
}
|