mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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)
This commit is contained in:
@@ -30,22 +30,24 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: [
|
||||
"bg-white text-neutral-950 font-medium",
|
||||
"shadow-btn-primary",
|
||||
"hover:bg-neutral-200",
|
||||
"active:scale-[0.97]",
|
||||
"enabled:hover:bg-neutral-200",
|
||||
"enabled:active:scale-[0.97]",
|
||||
].join(" "),
|
||||
secondary: [
|
||||
"bg-transparent text-neutral-300 font-medium",
|
||||
"border border-border",
|
||||
"hover:bg-surface-hover hover:text-white hover:border-border-strong",
|
||||
"active:scale-[0.98]",
|
||||
"enabled:hover:bg-surface-hover enabled:hover:text-white enabled:hover:border-border-strong",
|
||||
"enabled:active:scale-[0.98]",
|
||||
].join(" "),
|
||||
danger: [
|
||||
"bg-accent-red text-white font-medium",
|
||||
"enabled:hover:bg-red-600",
|
||||
"enabled:active:scale-[0.97]",
|
||||
].join(" "),
|
||||
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(
|
||||
" ",
|
||||
),
|
||||
ghost: [
|
||||
"bg-transparent text-neutral-400",
|
||||
"hover:bg-surface-hover hover:text-white",
|
||||
"active:scale-[0.98]",
|
||||
"enabled:hover:bg-surface-hover enabled:hover:text-white",
|
||||
"enabled:active:scale-[0.98]",
|
||||
].join(" "),
|
||||
};
|
||||
|
||||
@@ -55,6 +57,8 @@ const sizeStyles: Record<ButtonSize, string> = {
|
||||
lg: "h-9 px-4 text-base gap-2 rounded-button",
|
||||
};
|
||||
|
||||
// Imported by the shell/renders PRs later in this stack.
|
||||
// fallow-ignore-next-line unused-export
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
@@ -76,8 +80,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={[
|
||||
"inline-flex items-center justify-center",
|
||||
"transition-all duration-press ease-standard",
|
||||
"disabled:opacity-40 disabled:pointer-events-none",
|
||||
// No pointer-events-none: disabled buttons must still receive hover
|
||||
// so a wrapping Tooltip can explain WHY they're disabled (A5).
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
"select-none cursor-pointer",
|
||||
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className,
|
||||
@@ -126,6 +133,7 @@ const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
lg: "min-w-9 min-h-9 rounded-button", // 36px
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line unused-export
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
|
||||
return (
|
||||
@@ -134,8 +142,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
className={[
|
||||
"inline-flex items-center justify-center",
|
||||
"transition-all duration-press ease-standard",
|
||||
"disabled:opacity-40 disabled:pointer-events-none",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
"select-none cursor-pointer",
|
||||
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
||||
variantStyles[variant],
|
||||
iconSizeStyles[size],
|
||||
className,
|
||||
|
||||
@@ -25,7 +25,7 @@ export function HyperframesLoader({
|
||||
const markFrameSize = Math.round(size * 1.16);
|
||||
|
||||
return (
|
||||
<div className="hf-loader" draggable={false}>
|
||||
<div className="hf-loader" role="status" draggable={false}>
|
||||
<div
|
||||
className="hf-loader-mark-frame"
|
||||
style={{ width: markFrameSize, height: markFrameSize }}
|
||||
@@ -83,7 +83,13 @@ export function HyperframesLoader({
|
||||
<div className="hf-loader-title">{title}</div>
|
||||
{detail && <div className="hf-loader-detail">{detail}</div>}
|
||||
{boundedProgress !== undefined && (
|
||||
<div className="hf-loader-progress" aria-hidden="true">
|
||||
<div
|
||||
className="hf-loader-progress"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(boundedProgress * 100)}
|
||||
>
|
||||
<div
|
||||
className="hf-loader-progress__fill"
|
||||
style={{ transform: `scaleX(${boundedProgress})` }}
|
||||
@@ -95,6 +101,7 @@ export function HyperframesLoader({
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unused-export
|
||||
export function StatusFrame(props: HyperframesLoaderProps) {
|
||||
return (
|
||||
<div className="hf-frame">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// fallow-ignore-file unused-file
|
||||
// (consumers land in the sidebar/panels PR later in this stack)
|
||||
import { type InputHTMLAttributes } from "react";
|
||||
|
||||
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
/** Accessible name — placeholder alone is not one. */
|
||||
"aria-label": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared search input — one visual system (panel-input tokens) for every
|
||||
* panel search box, with a required accessible name.
|
||||
*/
|
||||
export function SearchInput({ className = "", ...props }: SearchInputProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] ${className}`}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 256 256"
|
||||
fill="none"
|
||||
className="flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="116"
|
||||
cy="116"
|
||||
r="76"
|
||||
stroke="currentColor"
|
||||
strokeWidth="22"
|
||||
className="text-panel-text-5"
|
||||
/>
|
||||
<line
|
||||
x1="170"
|
||||
y1="170"
|
||||
x2="232"
|
||||
y2="232"
|
||||
stroke="currentColor"
|
||||
strokeWidth="22"
|
||||
strokeLinecap="round"
|
||||
className="text-panel-text-5"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, type ReactNode } from "react";
|
||||
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
@@ -8,22 +8,46 @@ interface TooltipProps {
|
||||
side?: "top" | "bottom";
|
||||
}
|
||||
|
||||
// Rough bubble height (padding + one text line) used to decide flipping
|
||||
// before the bubble has rendered; exact height isn't needed for the guard.
|
||||
const APPROX_BUBBLE_H = 28;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
|
||||
export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
// WCAG 4.1.2: programmatically associate the bubble with its trigger.
|
||||
const tooltipId = useId();
|
||||
|
||||
const show = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const child = el.firstElementChild as HTMLElement | null;
|
||||
const rect = (child ?? el).getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) return;
|
||||
// Flip when the preferred side would clip the viewport edge.
|
||||
let nextSide = side;
|
||||
if (side === "top" && rect.top - APPROX_BUBBLE_H - 6 < VIEWPORT_MARGIN) {
|
||||
nextSide = "bottom";
|
||||
} else if (
|
||||
side === "bottom" &&
|
||||
rect.bottom + APPROX_BUBBLE_H + 6 > window.innerHeight - VIEWPORT_MARGIN
|
||||
) {
|
||||
nextSide = "top";
|
||||
}
|
||||
const x = Math.min(
|
||||
Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
|
||||
window.innerWidth - VIEWPORT_MARGIN,
|
||||
);
|
||||
setResolvedSide(nextSide);
|
||||
setPos({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: side === "top" ? rect.top - 6 : rect.bottom + 6,
|
||||
x,
|
||||
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
|
||||
});
|
||||
setVisible(true);
|
||||
}, delay);
|
||||
@@ -37,9 +61,27 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
// WCAG 1.4.13: tooltip content must be dismissible with Escape.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") hide();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [visible, hide]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={triggerRef} onPointerEnter={show} onPointerLeave={hide} className="contents">
|
||||
<span
|
||||
ref={triggerRef}
|
||||
onPointerEnter={show}
|
||||
onPointerLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
aria-describedby={visible ? tooltipId : undefined}
|
||||
className="contents"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{visible &&
|
||||
@@ -49,10 +91,14 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
||||
style={{
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
transform: side === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
||||
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
||||
}}
|
||||
>
|
||||
<div className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg">
|
||||
<div
|
||||
role="tooltip"
|
||||
id={tooltipId}
|
||||
className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -5,10 +5,19 @@ import { useState, useEffect } from "react";
|
||||
* video + canvas. Seeks to ~10% of duration to avoid black opening frames.
|
||||
* Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
|
||||
*/
|
||||
export function VideoFrameThumbnail({ src }: { src: string }) {
|
||||
export function VideoFrameThumbnail({
|
||||
src,
|
||||
fallbackLabel,
|
||||
}: {
|
||||
src: string;
|
||||
/** Shown instead of an endless shimmer when the video can't be decoded. */
|
||||
fallbackLabel?: string;
|
||||
}) {
|
||||
const [frame, setFrame] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
const video = document.createElement("video");
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
@@ -35,15 +44,29 @@ export function VideoFrameThumbnail({ src }: { src: string }) {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
video.addEventListener("error", cleanup);
|
||||
video.addEventListener("error", () => {
|
||||
// Resolve the loading state — a permanent shimmer reads as "still loading".
|
||||
setFailed(true);
|
||||
cleanup();
|
||||
});
|
||||
video.src = src;
|
||||
video.load();
|
||||
|
||||
return cleanup;
|
||||
}, [src]);
|
||||
|
||||
if (failed && !frame) {
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-800 flex items-center justify-center">
|
||||
<span className="text-[9px] font-medium text-neutral-600">{fallbackLabel ?? "VIDEO"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!frame) {
|
||||
return <div className="w-full h-full bg-neutral-800 animate-pulse" />;
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-800 animate-pulse motion-reduce:animate-none" />
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -251,3 +251,64 @@ body {
|
||||
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
/* Toast enter/exit (StudioToast) */
|
||||
@keyframes hf-toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hf-toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.hf-toast-enter {
|
||||
animation: hf-toast-in 160ms ease-out;
|
||||
}
|
||||
|
||||
.hf-toast-exit {
|
||||
animation: hf-toast-out 160ms ease-in forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hf-toast-enter,
|
||||
.hf-toast-exit {
|
||||
animation: none;
|
||||
}
|
||||
.hf-toast-exit {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay/backdrop entrance — the heaviest visual changes shouldn't pop in */
|
||||
@keyframes hf-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hf-backdrop-in {
|
||||
animation: hf-backdrop-in 150ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hf-backdrop-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user