- ⚠️ Large file - processing may take several minutes
-
-
-
}
- title="Trim"
- isOpen={openSections.trim}
- onToggle={() => toggleSection("trim")}
- delay={50}
- >
-
-
-
-
}
- title="Rotation"
- isOpen={openSections.rotation}
- onToggle={() => toggleSection("rotation")}
- delay={100}
- >
-
-
-
-
}
- title="Text Overlay"
- isOpen={openSections.text}
- onToggle={() => toggleSection("text")}
- delay={110}
- >
-
-
-
-
- ›
- Advanced settings
-
-
-
-
-
}
- title="Rotation"
- isOpen={openSections.rotation}
- onToggle={() => toggleSection("rotation")}
- >
-
-
-
-
}
- title="Export"
- isOpen={openSections.export}
- onToggle={() => toggleSection("export")}
- >
-
-
-
-
-
-
-
}
- title="Audio & Speed"
- isOpen={openSections.audio}
- onToggle={() => toggleSection("audio")}
- delay={150}
- >
-
-
-
}
- title="Adjustments"
- delay={175}
- >
-
- {/* Brightness */}
-
-
-
-
-
-
updateRecipe({ brightness: Number(e.target.value) })}
- aria-label="Adjust brightness"
- className="w-full accent-film-600"
- />
-
- {/* Contrast */}
-
-
-
-
-
-
updateRecipe({ contrast: Number(e.target.value) })}
- aria-label="Adjust contrast"
- className="w-full accent-film-600"
- />
-
- {/* Saturation */}
-
-
-
-
-
-
updateRecipe({ saturation: Number(e.target.value) })}
- aria-label="Adjust saturation"
- className="w-full accent-film-600"
- />
-
-
-
-
} title="Output format" delay={190}>
-
-
-
}
- title="Export"
- isOpen={openSections.export}
- onToggle={() => toggleSection("export")}
- delay={200}
- >
-
-
-
} title="Image overlay" delay={120}>
-
-
-
-
- ›
- Advanced settings
-
-
-
-
-
}
- title="Rotation"
- isOpen={openSections.rotation}
- onToggle={() => toggleSection("rotation")}
- >
-
-
-
-
}
- title="Export"
- isOpen={openSections.export}
- onToggle={() => toggleSection("export")}
- >
-
-
-
-
-
+ {!file && (
+
+
Upload a video to get started
+
Supports MP4, MOV, WebM and more
)}
- {status === "error" && error && (
-
-
-
-
Error
-
{error}
+ {file && (
+
+
+
+
+
-
- {!error.includes("Validation Failed") && (
-
- )}
-
- )}
-
- {status === "done" && result && (
-
-
)}
-
+ {file && file.size > 100 * 1024 * 1024 && (
+
+
+ ⚠️ Large file - processing may take several minutes
+
+
+ )}
+
+ {/* BLOCK 2: Preset & Framing (Resize & Aspect Ratio) (Right Column) */}
+
0 || exportHeight > 0 ? `${stickyOffset}px` : undefined,
+ animationDelay: "50ms"
+ }}
+ >
{!file && (
-
+
Getting Started
@@ -700,7 +488,11 @@ export default function VideoEditor() {
)}
-
+
+
}
@@ -740,7 +532,309 @@ export default function VideoEditor() {
+
+ {/* BLOCK 3: Trim & Rotate (Left Column) */}
+ {file && (
+
+
+
}
+ title="Trim"
+ isOpen={openSections.trim}
+ onToggle={() => toggleSection("trim")}
+ delay={50}
+ >
+
+
+
+
}
+ title="Rotation"
+ isOpen={openSections.rotation}
+ onToggle={() => toggleSection("rotation")}
+ delay={100}
+ >
+
+
+
+
}
+ title="Text Overlay"
+ isOpen={openSections.text}
+ onToggle={() => toggleSection("text")}
+ delay={110}
+ >
+
+
+
+
+ ›
+ Advanced settings
+
+
+
+
+
}
+ title="Rotation"
+ isOpen={openSections.rotation}
+ onToggle={() => toggleSection("rotation")}
+ >
+
+
+
+
}
+ title="Export"
+ isOpen={openSections.export}
+ onToggle={() => toggleSection("export")}
+ >
+
+
+
+
+
+
+ )}
+
+ {/* BLOCK 4: Audio & Quality/Export Settings (Left Column) */}
+ {file && (
+
+
+
}
+ title="Audio & Speed"
+ isOpen={openSections.audio}
+ onToggle={() => toggleSection("audio")}
+ delay={150}
+ >
+
+
+
}
+ title="Adjustments"
+ delay={175}
+ >
+
+ {/* Brightness */}
+
+
+
+
+
+
updateRecipe({ brightness: Number(e.target.value) })}
+ aria-label="Adjust brightness"
+ className="w-full accent-film-600"
+ />
+
+ {/* Contrast */}
+
+
+
+
+
+
updateRecipe({ contrast: Number(e.target.value) })}
+ aria-label="Adjust contrast"
+ className="w-full accent-film-600"
+ />
+
+ {/* Saturation */}
+
+
+
+
+
+
updateRecipe({ saturation: Number(e.target.value) })}
+ aria-label="Adjust saturation"
+ className="w-full accent-film-600"
+ />
+
+
+
+
} title="Output format" delay={190}>
+
+
+
}
+ title="Export"
+ isOpen={openSections.export}
+ onToggle={() => toggleSection("export")}
+ delay={200}
+ >
+
+
+
} title="Image overlay" delay={120}>
+
+
+
+
+ ›
+ Advanced settings
+
+
+
+
+
}
+ title="Rotation"
+ isOpen={openSections.rotation}
+ onToggle={() => toggleSection("rotation")}
+ >
+
+
+
+
}
+ title="Export"
+ isOpen={openSections.export}
+ onToggle={() => toggleSection("export")}
+ >
+
+
+
+
+
+
+ )}
+
+ {/* STATUS & RESULTS: (Left Column) */}
+ {status === "error" && error && (
+
+
+
+
+ {!error.includes("Validation Failed") && (
+
+ )}
+
+ )}
+
+ {status === "done" && result && (
+
+
+
+ )}
+
+ {/* BLOCK 5: Keyboard Shortcuts & Export Button (Right Column) */}
+
0 ? `${stickyOffset + resizeHeight + 20}px` : undefined
+ }}
+ >
{file && (
@@ -753,10 +847,10 @@ export default function VideoEditor() {
id="export-button"
type="button"
onClick={handleExport}
- disabled={!file || isProcessing}
- aria-label='Export video'
- aria-disabled={!file || isProcessing ? "true" : undefined}
- title={!file ? "Upload a video to enable export" : undefined}
+ disabled={!file || isProcessing}
+ aria-label='Export video'
+ aria-disabled={!file || isProcessing ? "true" : undefined}
+ title={!file ? "Upload a video to enable export" : undefined}
className={cn(
"w-full flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl",
"font-display text-2xl tracking-widest transition-all duration-200",
@@ -765,16 +859,19 @@ export default function VideoEditor() {
: "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed"
)}
>
-
+
{isProcessing ? "PROCESSING" : "EXPORT"}
- {file && !isProcessing && (
+ {!isProcessing && (
{isMac ? "⌘" : "Ctrl"} + Enter to export
)}
+
+ {/* CLEAR-FIX element to wrap floats inside parent */}
+
diff --git a/src/components/__tests__/VideoEditorTabOrder.test.tsx b/src/components/__tests__/VideoEditorTabOrder.test.tsx
new file mode 100644
index 00000000..7f0a69af
--- /dev/null
+++ b/src/components/__tests__/VideoEditorTabOrder.test.tsx
@@ -0,0 +1,111 @@
+import React from 'react'
+import { describe, it, expect, vi } from 'vitest'
+import '@testing-library/jest-dom/vitest'
+import { render } from '@testing-library/react'
+import VideoEditor from '../VideoEditor'
+
+// Mock sub-hooks and sub-components that are not necessary for testing DOM layout structure
+vi.mock('@/hooks/useVideoEditor', () => ({
+ useVideoEditor: () => ({
+ file: new File([""], "test.mp4", { type: "video/mp4" }),
+ duration: 60,
+ recipe: {
+ preset: 'vertical-9-16',
+ customWidth: 1080,
+ customHeight: 1920,
+ framing: 'fill',
+ trimStart: 0,
+ trimEnd: null,
+ rotate: 0,
+ speed: 1,
+ quality: 23,
+ format: 'mp4',
+ brightness: 0,
+ contrast: 1,
+ saturation: 1,
+ soundOnCompletion: true,
+ },
+ status: 'idle',
+ progress: 0,
+ result: null,
+ error: null,
+ exportStartedAt: null,
+ updateRecipe: vi.fn(),
+ handleFileSelect: vi.fn(),
+ fileError: '',
+ handleExport: vi.fn(),
+ cancelExport: vi.fn(),
+ reset: vi.fn(),
+ resetSettings: vi.fn(),
+ videoRef: { current: null },
+ seekTo: vi.fn(),
+ overlayFile: null,
+ setOverlayFile: vi.fn(),
+ overlayPosition: 'bottom-right',
+ setOverlayPosition: vi.fn(),
+ overlaySize: 150,
+ setOverlaySize: vi.fn(),
+ overlayOpacity: 100,
+ setOverlayOpacity: vi.fn(),
+ recommendedPreset: null,
+ currentTime: 0,
+ toggleSound: vi.fn(),
+ })
+}))
+
+vi.mock('@/hooks/useKeyboardShortcuts', () => ({
+ useKeyboardShortcuts: vi.fn()
+}))
+
+vi.mock('../LottiePlayer', () => ({
+ default: () =>
+}))
+
+describe('VideoEditor Tab Order & DOM Restructure', () => {
+ it('reorders DOM elements in logical reading/tab order: Upload -> Preset -> Framing -> Trim -> Rotate -> Audio -> Quality -> Export', () => {
+ const { container } = render(React.createElement(VideoEditor))
+
+ // Query for key elements representing each step
+ const fileUpload = container.querySelector('[id="upload-zone"]') || container.querySelector('button.text-film-600');
+ const resizeSection = container.querySelector('[aria-controls="resize-panel"]');
+ const trimSection = container.querySelector('[aria-controls="trim-panel"]');
+ const rotateSection = Array.from(container.querySelectorAll('[aria-controls="rotation-panel"]')).find(el => !el.closest('details'));
+ const audioSection = container.querySelector('[aria-controls="audio-panel"]');
+ const qualitySection = Array.from(container.querySelectorAll('[aria-controls="export-panel"]')).find(el => !el.closest('details'));
+ const exportButton = container.querySelector('#export-button');
+
+ // All controls must exist
+ expect(fileUpload).toBeTruthy();
+ expect(resizeSection).toBeTruthy();
+ expect(trimSection).toBeTruthy();
+ expect(rotateSection).toBeTruthy();
+ expect(audioSection).toBeTruthy();
+ expect(qualitySection).toBeTruthy();
+ expect(exportButton).toBeTruthy();
+
+ // Verify sequential DOM positions to guarantee logical focus order
+ const elementsInDOMOrder = [
+ fileUpload,
+ resizeSection,
+ trimSection,
+ rotateSection,
+ audioSection,
+ qualitySection,
+ exportButton
+ ];
+
+ // Find all focusable elements in the rendered container to verify their raw DOM order
+ const allRenderedElements = Array.from(container.querySelectorAll('*'));
+
+ const indices = elementsInDOMOrder.map(el => allRenderedElements.indexOf(el!));
+
+ // Check that each index is strictly greater than the previous one
+ for (let i = 0; i < indices.length - 1; i++) {
+ const current = indices[i];
+ const next = indices[i + 1];
+ expect(current).toBeDefined();
+ expect(next).toBeDefined();
+ expect(current!).toBeLessThan(next!);
+ }
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index 220799df..968b8956 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,4 +1,5 @@
import { defineConfig } from 'vitest/config'
+import path from 'path'
export default defineConfig({
oxc: {
@@ -6,6 +7,11 @@ export default defineConfig({
runtime: 'automatic',
},
},
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
test: {
environment: 'jsdom',
globals: true,