diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 0a0a327e7a..42b8ecb437 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -154,7 +154,8 @@ export const AnimationCard = memo(function AnimationCard({ @@ -274,6 +294,11 @@ export const FileTree = memo(function FileTree({ onCancel={() => inlineInput.onCancel?.()} /> )} + {children.length === 0 && !inlineInput && ( +
+ No files yet{hasFileOps ? " — use + above to create one" : ""}. +
+ )} {children.map((child) => child.isFile && child.children.size === 0 ? ( - {/* Delete confirmation overlay */} + {/* Delete confirmation — anchored near the row it was invoked on */} {deleteTarget && ( -
+
diff --git a/packages/studio/src/components/editor/FileTreeNodes.tsx b/packages/studio/src/components/editor/FileTreeNodes.tsx index 983aae4c0f..4e90d62d96 100644 --- a/packages/studio/src/components/editor/FileTreeNodes.tsx +++ b/packages/studio/src/components/editor/FileTreeNodes.tsx @@ -43,9 +43,14 @@ export function ContextMenu({ onDelete: (path: string) => void; }) { const menuRef = useRef(null); + const restoreFocusRef = useRef(null); // eslint-disable-next-line no-restricted-syntax useEffect(() => { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const firstItem = menuRef.current?.querySelector("button"); + if (firstItem instanceof HTMLElement) firstItem.focus(); const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose(); @@ -59,9 +64,28 @@ export function ContextMenu({ return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); + restoreFocusRef.current?.focus(); }; }, [onClose]); + const handleMenuKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") { + return; + } + const menu = menuRef.current; + if (!menu) return; + e.preventDefault(); + const items = Array.from(menu.querySelectorAll("button")); + if (items.length === 0) return; + const current = items.indexOf(document.activeElement as HTMLButtonElement); + let next = 0; + if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length; + else if (e.key === "ArrowUp") { + next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length; + } else if (e.key === "End") next = items.length - 1; + items[next]?.focus(); + }; + const adjustedX = Math.min(state.x, window.innerWidth - 180); const adjustedY = Math.min(state.y, window.innerHeight - 200); @@ -74,13 +98,16 @@ export function ContextMenu({ return (
{state.targetIsFolder && ( <> {!state.targetIsFolder && (
); } @@ -238,10 +297,12 @@ export function InlineInput({ export function DeleteConfirm({ name, + isFolder, onConfirm, onCancel, }: { name: string; + isFolder?: boolean; onConfirm: () => void; onCancel: () => void; }) { @@ -269,7 +330,16 @@ export function DeleteConfirm({ className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs" >

- Delete {name}? + {isFolder ? ( + <> + Delete folder {name} and + everything inside it? + + ) : ( + <> + Delete {name}? + + )}

@@ -160,7 +162,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({ type="button" disabled={!nextKf} onClick={() => nextKf && onSeek(nextKf.percentage)} - className="flex h-5 w-3 items-center justify-center disabled:cursor-default" + title="Next keyframe" + aria-label={`Next ${property} keyframe`} + className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']" > diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 15ffedca11..10e8d1bb2a 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -333,14 +333,23 @@ export const LayersPanel = memo(function LayersPanel() { : selected ? "bg-panel-accent/14 text-panel-accent" : "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1" - } ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`} + } ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`} style={{ paddingLeft: 8 + layer.depth * 16 }} + title={ + draggable + ? layer.element.hasAttribute("data-hf-group") + ? "Double-click to enter group" + : undefined + : "This layer can't be reordered" + } > {hasChildren ? ( + {gridPopoverOpen && (
) => { + const STEP = e.altKey ? 1 : 5; + let next: CubePose | null = null; + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + const dir = e.key === "ArrowUp" ? -1 : 1; + next = e.shiftKey + ? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) } + : { ...shown, rotationX: wrapDeg(shown.rotationX + dir * STEP) }; + } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + const dir = e.key === "ArrowRight" ? 1 : -1; + next = e.shiftKey + ? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) } + : { ...shown, rotationY: wrapDeg(shown.rotationY + dir * STEP) }; + } + if (!next) return; + e.preventDefault(); + onPoseCommit(next); + }; + return (
setCollapsed((v) => !v)} - className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400" + aria-expanded={!collapsed} + className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400 active:scale-[0.99]" > 3D Transform - - {collapsed ? : } + + {collapsed ? null : ( diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index 962f0e9172..251822d1ae 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -181,6 +181,10 @@ export function ColorField({ useEffect(() => { if (!open) return; + // Move focus into the picker on open and restore it on close so Escape + // and keyboard editing work without a pointer round-trip. + panelRef.current?.focus(); + const restoreTarget = buttonRef.current; const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node | null; if (!target) return; @@ -188,7 +192,10 @@ export function ColorField({ setOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); + if (event.key === "Escape") { + setOpen(false); + restoreTarget?.focus(); + } }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); @@ -232,7 +239,10 @@ export function ColorField({ ? createPortal(
void; }; +type DetailSettings = "vignette" | "grain"; +type ColorGradingCommit = (nextGrading: NormalizedHfColorGrading) => void; const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; @@ -141,6 +151,7 @@ const EFFECT_SLIDERS: Array<{ { key: "blur", label: "Blur", min: 0, max: 100, step: 1, scale: 100, suffix: "%" }, { key: "pixelate", label: "Pixelate", min: 0, max: 100, step: 1, scale: 100, suffix: "%" }, ]; +type EffectSlider = (typeof EFFECT_SLIDERS)[number]; const AMOUNT_DETAIL_SLIDERS = DETAIL_SLIDERS.filter( (slider) => slider.key === "vignette" || slider.key === "grain", @@ -164,6 +175,157 @@ function visibleIntensity(grading: NormalizedHfColorGrading): number { return grading.intensity === 0 ? 1 : grading.intensity; } +function PresetControls({ + grading, + onApplyPreset, + onUpdateIntensity, +}: { + grading: NormalizedHfColorGrading; + onApplyPreset: (preset: string) => void; + onUpdateIntensity: (value: number) => void; +}) { + return ( + <> + + onUpdateIntensity(100)} + /> + + ); +} + +function DetailControls({ + detailSettings, + detailSettingsSliders, + vignetteSettingsActive, + grainSettingsActive, + onSetDetailSettings, + onRenderSlider, +}: { + detailSettings: DetailSettings | null; + detailSettingsSliders: DetailSlider[]; + vignetteSettingsActive: boolean; + grainSettingsActive: boolean; + onSetDetailSettings: (updater: (current: DetailSettings | null) => DetailSettings | null) => void; + onRenderSlider: (slider: DetailSlider, settings?: SliderSettings) => ReactElement; +}) { + return ( +
+ Finishing +
+ {AMOUNT_DETAIL_SLIDERS.map((slider) => + onRenderSlider(slider, { + active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive, + label: `${slider.label} settings`, + onClick: () => + onSetDetailSettings((current) => + current === slider.key ? null : (slider.key as DetailSettings), + ), + }), + )} +
+ {detailSettings && ( +
+
+ + {detailSettings === "vignette" ? "Vignette settings" : "Grain settings"} + + +
+
+ {detailSettingsSliders.map((slider) => onRenderSlider(slider))} +
+
+ )} +
+ ); +} + +function EffectControls({ + grading, + onCommitColorGrading, +}: { + grading: NormalizedHfColorGrading; + onCommitColorGrading: ColorGradingCommit; +}) { + const commitEffect = (slider: EffectSlider, next: number) => { + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + effects: { + ...grading.effects, + [slider.key]: next / slider.scale, + }, + }); + }; + const resetEffect = (slider: EffectSlider) => { + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + effects: { + ...grading.effects, + [slider.key]: 0, + }, + }); + }; + + return ( +
+ Effects +
+ {EFFECT_SLIDERS.map((slider) => { + const value = grading.effects[slider.key] * slider.scale; + return ( + commitEffect(slider, next)} + onReset={() => resetEffect(slider)} + /> + ); + })} +
+
+ ); +} + export function ColorGradingControls({ grading, assets, @@ -178,6 +340,8 @@ export function ColorGradingControls({ const lutInputRef = useRef(null); const [lutOpen, setLutOpen] = useState(false); const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null); + const [lutImporting, setLutImporting] = useState(false); + const [lutImportError, setLutImportError] = useState(null); const lutAssets = useMemo( () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)), [assets], @@ -194,6 +358,9 @@ export function ColorGradingControls({ ); const applyPreset = (preset: string) => { + // Pass the LUT through normalize; the preset's own adjust values are its + // look (main's normalize already avoids carrying over grading.adjust, + // which was the bug this PR originally fixed). const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut }); if (next) onCommitColorGrading(next); }; @@ -214,11 +381,30 @@ export function ColorGradingControls({ if (!grading.lut) return; applyLut(grading.lut.src, value / 100); }; + const selectLut = (src: string) => { + applyLut(src || null, src && grading.lut?.src === src ? grading.lut.intensity : 1); + }; + const handleLutImportClick = (event: MouseEvent) => { + event.stopPropagation(); + lutInputRef.current?.click(); + }; + const handleLutFilesChange = (event: ChangeEvent) => { + void importLuts(event.currentTarget.files); + event.currentTarget.value = ""; + }; const importLuts = async (files: FileList | null) => { if (!files?.length || !onImportAssets) return; - const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR); - const firstLut = uploaded.find((asset) => LUT_EXT.test(asset)); - if (firstLut) applyLut(firstLut, 1); + setLutImporting(true); + setLutImportError(null); + try { + const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR); + const firstLut = uploaded.find((asset) => LUT_EXT.test(asset)); + if (firstLut) applyLut(firstLut, 1); + } catch { + setLutImportError("LUT import failed — check the .cube file and try again."); + } finally { + setLutImporting(false); + } }; const commitDetailSlider = (slider: DetailSlider, next: number) => { onCommitColorGrading({ @@ -262,129 +448,28 @@ export function ColorGradingControls({ return (
- - updateFilterIntensity(100)} + -
- - {lutOpen && ( -
-
- - - { - void importLuts(event.currentTarget.files); - event.currentTarget.value = ""; - }} - /> -
- {grading.lut && ( -
- {selectedProjectLut && ( -
- - - Uploaded LUT - {` · ${selectedProjectLut}`} - -
- )} - updateLutIntensity(100)} - /> -
- )} -
- )} -
+ setLutOpen((value) => !value)} + onSelectLut={selectLut} + onImportClick={handleLutImportClick} + onFilesChange={handleLutFilesChange} + onUpdateIntensity={updateLutIntensity} + />
Adjust @@ -434,84 +519,16 @@ export function ColorGradingControls({
-
- Finishing -
- {AMOUNT_DETAIL_SLIDERS.map((slider) => - renderDetailSlider(slider, { - active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive, - label: `${slider.label} settings`, - onClick: () => - setDetailSettings((current) => - current === slider.key ? null : (slider.key as "vignette" | "grain"), - ), - }), - )} -
- {detailSettings && ( -
-
- - {detailSettings === "vignette" ? "Vignette settings" : "Grain settings"} - - -
-
- {detailSettingsSliders.map((slider) => renderDetailSlider(slider))} -
-
- )} -
+ -
- Effects -
- {EFFECT_SLIDERS.map((slider) => { - const value = grading.effects[slider.key] * slider.scale; - return ( - { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - effects: { - ...grading.effects, - [slider.key]: next / slider.scale, - }, - }); - }} - onReset={() => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - effects: { - ...grading.effects, - [slider.key]: 0, - }, - }); - }} - /> - ); - })} -
-
+
); } diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingLutControls.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingLutControls.tsx new file mode 100644 index 0000000000..9702930ae6 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorGradingLutControls.tsx @@ -0,0 +1,257 @@ +import type { ChangeEvent, MouseEvent, RefObject } from "react"; +import type { NormalizedHfColorGrading } from "@hyperframes/core/color-grading"; +import { ChevronDown, ChevronRight, Plus } from "../../icons/SystemIcons"; +import { ColorGradingSliderControl } from "./propertyPanelColorGradingSlider"; + +function LutHeader({ + lutOpen, + hasLut, + onToggleOpen, +}: { + lutOpen: boolean; + hasLut: boolean; + onToggleOpen: () => void; +}) { + return ( + + ); +} + +function LutAssetSelect({ + selectedLut, + lutAssets, + onSelectLut, +}: { + selectedLut: string; + lutAssets: string[]; + onSelectLut: (src: string) => void; +}) { + return ( + + ); +} + +function LutImportButton({ + canImportLuts, + lutImporting, + lutInputRef, + onImportClick, + onFilesChange, +}: { + canImportLuts: boolean; + lutImporting: boolean; + lutInputRef: RefObject; + onImportClick: (event: MouseEvent) => void; + onFilesChange: (event: ChangeEvent) => void; +}) { + return ( + <> + + + + ); +} + +function LutImportError({ message }: { message: string | null }) { + if (!message) return null; + + return ( +
+ {message} +
+ ); +} + +function SelectedLutSummary({ selectedProjectLut }: { selectedProjectLut: string | null }) { + if (!selectedProjectLut) return null; + + return ( +
+ + + Uploaded LUT + {` · ${selectedProjectLut}`} + +
+ ); +} + +function LutStrengthControl({ + lut, + onUpdateIntensity, +}: { + lut: NormalizedHfColorGrading["lut"]; + onUpdateIntensity: (value: number) => void; +}) { + if (!lut) return null; + + const strength = Math.round((lut.intensity ?? 1) * 100); + return ( + onUpdateIntensity(100)} + /> + ); +} + +function LutPanel({ + grading, + lutAssets, + selectedLut, + selectedProjectLut, + lutImporting, + lutImportError, + canImportLuts, + lutInputRef, + onSelectLut, + onImportClick, + onFilesChange, + onUpdateIntensity, +}: { + grading: NormalizedHfColorGrading; + lutAssets: string[]; + selectedLut: string; + selectedProjectLut: string | null; + lutImporting: boolean; + lutImportError: string | null; + canImportLuts: boolean; + lutInputRef: RefObject; + onSelectLut: (src: string) => void; + onImportClick: (event: MouseEvent) => void; + onFilesChange: (event: ChangeEvent) => void; + onUpdateIntensity: (value: number) => void; +}) { + return ( +
+
+ + +
+ + {grading.lut && ( +
+ + +
+ )} +
+ ); +} + +export function LutControls({ + grading, + lutOpen, + lutAssets, + selectedLut, + selectedProjectLut, + lutImporting, + lutImportError, + canImportLuts, + lutInputRef, + onToggleOpen, + onSelectLut, + onImportClick, + onFilesChange, + onUpdateIntensity, +}: { + grading: NormalizedHfColorGrading; + lutOpen: boolean; + lutAssets: string[]; + selectedLut: string; + selectedProjectLut: string | null; + lutImporting: boolean; + lutImportError: string | null; + canImportLuts: boolean; + lutInputRef: RefObject; + onToggleOpen: () => void; + onSelectLut: (src: string) => void; + onImportClick: (event: MouseEvent) => void; + onFilesChange: (event: ChangeEvent) => void; + onUpdateIntensity: (value: number) => void; +}) { + return ( +
+ + {lutOpen && ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFill.tsx b/packages/studio/src/components/editor/propertyPanelFill.tsx index 3b1669e047..6f0c455bd2 100644 --- a/packages/studio/src/components/editor/propertyPanelFill.tsx +++ b/packages/studio/src/components/editor/propertyPanelFill.tsx @@ -89,6 +89,7 @@ export function ImageFillField({ }) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]); const selectedAsset = useMemo( () => resolveSelectedAsset(value, sourceFile, imageAssets), @@ -99,10 +100,13 @@ export function ImageFillField({ const handleUpload = async (files: FileList | null) => { if (!files?.length || !onImportAssets) return; setUploading(true); + setUploadError(null); try { const uploaded = await onImportAssets(files); const nextImage = uploaded.find((a) => IMAGE_EXT.test(a)); if (nextImage) onCommit(`url("${toProjectRootAssetPath(nextImage)}")`); + } catch { + setUploadError("Upload failed — check the file and try again."); } finally { setUploading(false); } @@ -139,6 +143,11 @@ export function ImageFillField({ }} />
+ {uploadError && ( +
+ {uploadError} +
+ )} {imageAssets.length > 0 ? (
{selectedAsset && ( @@ -253,11 +262,49 @@ export function GradientField({ {parsed.stops.map((stop, index) => (
{ + if (disabled) return; + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const step = event.shiftKey ? 10 : 1; + const delta = event.key === "ArrowRight" ? step : -step; + updateStop(index, { + position: Math.max(0, Math.min(100, Math.round(stop.position + delta))), + }); + }} + className="absolute top-1/2 h-4 w-4 -translate-y-1/2 cursor-ew-resize rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)] outline-none focus-visible:ring-2 focus-visible:ring-studio-accent" style={{ left: `calc(${stop.position}% - 8px)`, backgroundColor: stop.color, }} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => { + if (disabled) return; + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + if (disabled || !event.currentTarget.hasPointerCapture(event.pointerId)) return; + const rect = previewRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0) return; + const next = Math.max( + 0, + Math.min(100, ((event.clientX - rect.left) / rect.width) * 100), + ); + updateStop(index, { position: Math.round(next * 10) / 10 }); + }} + onPointerUp={(event) => { + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={(event) => { + event.currentTarget.releasePointerCapture(event.pointerId); + }} /> ))}
@@ -306,6 +353,7 @@ export function GradientField({
{parsed.kind === "linear" ? "Angle" : "Start angle"} Center X Center Y = 6} onClick={() => addStop()} - className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600" + title={parsed.stops.length >= 6 ? "Maximum 6 stops" : "Add a gradient stop"} + className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white active:scale-[0.98] disabled:cursor-not-allowed disabled:text-neutral-600" > Add stop diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index a0fd50d7ab..855e4370b7 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -139,6 +139,7 @@ export function FontFamilyField({ const fontInputRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(-1); const [localFonts, setLocalFonts] = useState([]); const [localFontData, setLocalFontData] = useState([]); const [googleFonts, setGoogleFonts] = useState(() => [...POPULAR_GOOGLE_FONT_FAMILIES]); @@ -251,6 +252,8 @@ export function FontFamilyField({ } else { setFontNotice("No supported font files were imported."); } + } catch { + setFontNotice("Font import failed — the files were not added. Try again."); } finally { setImportingFonts(false); } @@ -350,11 +353,17 @@ export function FontFamilyField({ onCommit(buildFontFamilyValue(imported.family)); setQuery(""); setOpen(false); - return; + } else { + // Committing an un-imported family would render a silent fallback, + // so surface the failure and keep the current font instead. + setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`); } + } catch { + setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`); } finally { setImportingFonts(false); } + return; } if (option.source === "Google") loadGoogleFontStylesheet(option.family); const imported = importedFonts.find( @@ -395,17 +404,39 @@ export function FontFamilyField({ value={query} disabled={disabled} placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"} - onChange={(e) => setQuery(e.target.value)} + onChange={(e) => { + setQuery(e.target.value); + setActiveIndex(-1); + }} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); + return; + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + if (filteredOptions.length === 0) return; + const delta = e.key === "ArrowDown" ? 1 : -1; + const next = + activeIndex < 0 && delta === 1 + ? 0 + : (activeIndex + delta + filteredOptions.length) % filteredOptions.length; + setActiveIndex(next); + document + .querySelector(`[data-font-option-index="${next}"]`) + ?.scrollIntoView({ block: "nearest" }); + return; } - if (e.key === "Enter" && filteredOptions[0]) { + const target = filteredOptions[activeIndex] ?? filteredOptions[0]; + if (e.key === "Enter" && target) { e.preventDefault(); - commitFamily(filteredOptions[0]); + commitFamily(target); } }} + role="combobox" + aria-expanded={open} + aria-autocomplete="list" className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600" /> {canQueryLocalFonts && ( @@ -449,19 +480,27 @@ export function FontFamilyField({ {filteredOptions.length === 0 ? (
No fonts found.
) : ( - filteredOptions.map((option) => ( + filteredOptions.map((option, index) => (