# Modal Z-Index Fix Implementation Plan **Date**: 2026-02-04 **Issue**: Modal overlay z-index conflicts preventing dropdown interactions **Scope**: 7 P0 critical modal components with native ` {/* BROKEN: Dropdown can't render above z-50 overlay */} ``` ### Solution: 3-Layer Modal Architecture Replace single-layer pattern with 3 distinct layers: ```tsx {/* FIXED: 3-layer architecture */} <> {/* Layer 1: Background overlay (z-40) - Click to close */}
{/* Layer 2: Container (z-50, pointer-events-none) - Centers content */}
{/* Layer 3: Content (pointer-events-auto) - Interactive form */}
``` **Key CSS Classes**: - `pointer-events-none` on container: Passes clicks through to overlay - `pointer-events-auto` on content: Re-enables form interactions - Background overlay at `z-40`: Lower than form container at `z-50` 3. **Native Dropdown Conflict**: Native HTML ` onChange(parseInt(e.target.value) || null)} className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" > {accessLists?.filter((acl) => acl.enabled).map((acl) => ( ))} ``` **Security Headers Dropdown** ([frontend/src/components/ProxyHostForm.tsx](frontend/src/components/ProxyHostForm.tsx#L797-L830)): ```tsx ``` **Problems**: - Native ` onChange(parseInt(e.target.value) || null)} // ✅ Handler is defined className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" > {accessLists?.filter((acl) => acl.enabled).map((acl) => ( ))} {/* ... display selected ACL details ... */}
); } ``` **Event Handler Analysis**: - ✅ `onChange` handler is correctly defined - ✅ Handler parses the selected value correctly - ✅ Handler calls parent's `onChange` prop with the correct value - ❌ **THE PROBLEM**: Parent modal overlay prevents click event from reaching the ` { const value = e.target.value === "0" ? null : parseInt(e.target.value) || null setFormData({ ...formData, security_header_profile_id: value }) // ✅ Handler is defined }} className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" > {securityProfiles?.filter(p => p.is_preset) .sort((a, b) => a.security_score - b.security_score) .map(profile => ( ))} {/* ... more profiles ... */} ); } ``` **Event Handler Analysis**: - ✅ `onChange` handler is correctly defined - ✅ Handler correctly parses "0" as null (unselect) - ✅ Handler correctly parses numeric values - ✅ Handler updates `formData` state correctly - ❌ **THE PROBLEM**: Parent modal overlay prevents click event from reaching the ` {/* Security Headers dropdown */} ) ``` **Z-Index Analysis**: - Outer wrapper: `z-50` with `fixed inset-0` (covers viewport) - Native `` dropdown menu in a popup layer 2. This popup layer has default z-index (often 0 or auto) 3. Parent's z-index: 50 creates a new stacking context 4. Dropdown menu tries to render in this context 5. If dropdown z-index < parent z-index, it's hidden behind the overlay --- ## Test Evidence & References ### E2E Test File Evidence **File**: [tests/integration/proxy-acl-integration.spec.ts](tests/integration/proxy-acl-integration.spec.ts#L130-L155) **Test Attempting to Select ACL** (currently failing): ```typescript await test.step('Assign IP-based whitelist ACL to proxy host', async () => { // Find the proxy host row and click edit const proxyRow = page.locator(SELECTORS.proxyRow).filter({ hasText: createdDomain, }); await expect(proxyRow).toBeVisible(); const editButton = proxyRow.locator(SELECTORS.proxyEditBtn).first(); await editButton.click(); await waitForModal(page, /edit|proxy/i); // Try to select ACL from dropdown const aclDropdown = page.locator(SELECTORS.aclSelectDropdown); const aclCombobox = page.getByRole('combobox') .filter({ hasText: /No Access Control|whitelist/i }); // Build the pattern to match the ACL name const aclNamePattern = aclConfig.name; if (await aclDropdown.isVisible()) { const option = aclDropdown.locator('option').filter({ hasText: aclNamePattern }); const optionValue = await option.getAttribute('value'); if (optionValue) { await aclDropdown.selectOption({ value: optionValue }); // ❌ THIS FAILS: selectOption doesn't work because overlay blocks interaction } } }); ``` **Test Selector References** ([tests/integration/proxy-acl-integration.spec.ts](tests/integration/proxy-acl-integration.spec.ts#L52-L54)): ```typescript const SELECTORS = { aclSelectDropdown: '[data-testid="acl-select"], select[name="access_list_id"]', // ... } ``` --- ### Known Pattern Reference: ConfigReloadOverlay **Similar problematic pattern** in [frontend/src/components/LoadingStates.tsx](frontend/src/components/LoadingStates.tsx#L251-L287): ```tsx export function ConfigReloadOverlay({ message = 'Ferrying configuration...', submessage = 'Charon is crossing the Styx', type = 'charon', }: { message?: string submessage?: string type?: 'charon' | 'coin' | 'cerberus' }) { return (
{/* Content */}
) } ``` **Test Helper Workaround** ([tests/utils/ui-helpers.ts](tests/utils/ui-helpers.ts#L247-L275)): ```typescript /** * ✅ FIX P0: Wait for ConfigReloadOverlay to disappear before clicking * The ConfigReloadOverlay component (z-50) intercepts pointer events * during Caddy config reloads, blocking all interactions. */ export async function clickSwitch( locator: Locator, options: SwitchOptions = {} ): Promise { // ... const page = locator.page(); const overlay = page.locator('[data-testid="config-reload-overlay"]'); // Wait for overlay to disappear before clicking await overlay.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => { // Overlay not present - continue }); // ... click the element ... } ``` This confirms the pattern is **known to cause problems** and requires workarounds. --- ## Detailed Fix Plan ### Solution Strategy **Overall Approach**: Restructure the modal DOM to separate the overlay from the form content, using proper z-index layering and pointer-events management. --- ### Fix 1: Restructure Modal Z-Index Hierarchy (CRITICAL) **File to Modify**: [frontend/src/components/ProxyHostForm.tsx](frontend/src/components/ProxyHostForm.tsx#L514-L525) **Current Code** (Lines 514-525): ```tsx return (

{host ? 'Edit Proxy Host' : 'Add Proxy Host'}

``` **Proposed Fix**: ```tsx return ( <> {/* Layer 1: Overlay (z-40) - separate from form to allow form to float above */}
{/* Layer 2: Form container (z-50) with pointer-events-none on wrapper */}
{/* Layer 3: Form content with pointer-events-auto to re-enable interactions */}

{host ? 'Edit Proxy Host' : 'Add Proxy Host'}

``` **Why This Works**: 1. **Separate Overlay Layer** (`z-40`): - Handles backdrop click-to-close - Doesn't interfere with form's z-index stacking - Can receive click events without blocking 2. **Form Container Layer** (`z-50` with `pointer-events-none`): - Higher z-index than overlay ensures form is on top visually - `pointer-events-none` means this div doesn't capture clicks - Clicks pass through to children 3. **Form Content** (`pointer-events-auto`): - Re-enables pointer events on actual form elements - Allows native ` dropdowns couldn't open or receive clicks because the overlay intercepted events. Changes: - Restructured modal DOM to separate overlay (z-40) from form (z-50) - Added pointer-events-none to form container, pointer-events-auto to form content to re-enable child interactions - This allows native