fix: use portal-based positioning for property panel dropdowns (#130)

* fix: use portal-based positioning for property panel dropdowns

StatusDropdown and DisplayModeSelector used absolute positioning
within the Inspector's overflow-hidden container, causing dropdowns
to be clipped when the Properties panel is narrow (200-280px).

Both now render via createPortal into document.body with fixed
positioning calculated from the trigger element's bounding rect.
This matches the pattern used by existing Radix UI components
(Select, Popover) in the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: property dropdown narrow panel fix — before/after frames

---------

Co-authored-by: Laputa App <laputa@app.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-27 19:00:30 +01:00
committed by GitHub
parent c03bc895a6
commit a3b7305e51
4 changed files with 140 additions and 80 deletions

View File

@@ -1,36 +1,3 @@
task: refactor-editor-notelist-sidebar
ui-change: no
## Summary
Refactored three CodeScene hotspot files to improve code health scores:
| File | Before | After | Approach |
|------|--------|-------|----------|
| Editor.tsx | 7.82 (cc=35) | 10.0 | Decomposed into EditorContent, EditorRightPanel, SingleEditorView, editorSchema, useDiffMode, useEditorFocus, suggestionEnrichment |
| NoteList.tsx | 9.04 (cc=13) | 9.38 | Extracted createNoteStatusResolver, toggleSetMember standalone functions |
| Sidebar.tsx | 9.09 (cc=10) | 10.0 | Extracted applyCustomization standalone function |
## New files
- `src/components/EditorContent.tsx` — breadcrumb bar + editor/diff view switching
- `src/components/EditorRightPanel.tsx` — AI chat / inspector panel toggle
- `src/components/SingleEditorView.tsx` — BlockNote editor with suggestion menus
- `src/components/editorSchema.tsx` — WikiLink inline content spec + BlockNote schema
- `src/hooks/useDiffMode.ts` — diff mode state management
- `src/hooks/useEditorFocus.ts` — editor focus on custom event
- `src/utils/suggestionEnrichment.ts` — shared suggestion item enrichment pipeline
## New tests (20 tests)
- `src/hooks/useDiffMode.test.ts` — 10 tests
- `src/hooks/useEditorFocus.test.ts` — 3 tests
- `src/utils/suggestionEnrichment.test.ts` — 7 tests
## Validation
- 976 tests pass (956 existing + 20 new)
- 80% frontend coverage (≥70% required)
- 270 Rust tests pass
- CodeScene quality gates: passed
- Zero behavior change — pure refactor
Task: 🐛 Property dropdown fix
Summary: Fixed dropdown collision/overflow when Properties panel is narrow. StatusDropdown and DisplayModeSelector were using absolute positioning within the Inspector's overflow-hidden container, causing clipping at narrow widths (200-280px). Both now render via createPortal with fixed positioning, matching the pattern used by existing Radix UI components. All 1017 frontend tests + 270 Rust tests pass.
Commits: 1

View File

@@ -0,0 +1,56 @@
{
"children": [
{
"type": "frame",
"id": "fpdn_before",
"name": "Property Dropdown Narrow — Before (clipped)",
"x": 0,
"y": 0,
"width": 280,
"height": 200,
"fill": "#1a1a1a",
"layout": "vertical",
"gap": 8,
"padding": 12,
"theme": "dark",
"children": [
{
"type": "text",
"id": "fpdn_before_label",
"content": "Bug: StatusDropdown clipped by overflow:hidden container at 200-280px panel width",
"fill": "#ff4444",
"fontFamily": "SF Pro Text",
"fontSize": 12,
"fontWeight": "regular",
"letterSpacing": 0
}
]
},
{
"type": "frame",
"id": "fpdn_after",
"name": "Property Dropdown Narrow — After (portal, no clip)",
"x": 320,
"y": 0,
"width": 280,
"height": 200,
"fill": "#1a1a1a",
"layout": "vertical",
"gap": 8,
"padding": 12,
"theme": "dark",
"children": [
{
"type": "text",
"id": "fpdn_after_label",
"content": "Fix: StatusDropdown + DisplayModeSelector use createPortal with fixed positioning — renders above overflow:hidden, visible at any panel width",
"fill": "#44bb44",
"fontFamily": "SF Pro Text",
"fontSize": 12,
"fontWeight": "regular",
"letterSpacing": 0
}
]
}
]
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -186,7 +187,19 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
onSelect: (key: string, mode: PropertyDisplayMode | null) => void
}) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const positionMenu = useCallback((node: HTMLDivElement | null) => {
if (!node) return
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const menuW = 140
let left = rect.right - menuW
if (left < 8) left = 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
const handleSelect = (mode: PropertyDisplayMode) => {
if (mode === autoMode) {
@@ -198,8 +211,9 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
}
return (
<div ref={containerRef} className="relative">
<div className="relative">
<button
ref={triggerRef}
className="flex h-4 w-4 items-center justify-center rounded border-none bg-transparent p-0 text-[10px] leading-none text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/prop:opacity-100"
onClick={() => setOpen(!open)}
title="Change display mode"
@@ -207,11 +221,12 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
>
{'\u25BE'}
</button>
{open && (
{open && createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} />
<div
className="absolute right-0 top-full z-[12001] mt-1 min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
ref={positionMenu}
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
data-testid="display-mode-menu"
>
{DISPLAY_MODE_OPTIONS.map(opt => (
@@ -231,7 +246,8 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
</button>
))}
</div>
</>
</>,
document.body
)}
</div>
)

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getStatusStyle, SUGGESTED_STATUSES, setStatusColor, getStatusColorKey } from '../utils/statusStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
@@ -227,6 +228,20 @@ export function StatusDropdown({
const [colorEditingStatus, setColorEditingStatus] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const positionDropdown = useCallback((node: HTMLDivElement | null) => {
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
@@ -268,46 +283,52 @@ export function StatusDropdown({
}
return (
<div className="relative" data-testid="status-dropdown">
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<div
className="absolute right-0 top-full z-[12001] mt-1 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="status-dropdown-popover"
>
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Type a status..."
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="status-search-input"
/>
</div>
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
<VaultSection statuses={vaultFiltered} {...optionProps} />
<SuggestedSection
statuses={suggestedFiltered}
showDivider={vaultFiltered.length > 0}
{...optionProps}
highlightOffset={vaultFiltered.length}
/>
<CreateSection
show={showCreateOption}
query={query}
showDivider={allFiltered.length > 0}
highlighted={highlightIndex === allFiltered.length}
onSave={onSave}
onMouseEnter={() => setHighlightIndex(allFiltered.length)}
/>
{allFiltered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching statuses
<div ref={anchorRef} data-testid="status-dropdown">
{createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<div
ref={positionDropdown}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="status-dropdown-popover"
>
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Type a status..."
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="status-search-input"
/>
</div>
)}
</div>
</div>
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
<VaultSection statuses={vaultFiltered} {...optionProps} />
<SuggestedSection
statuses={suggestedFiltered}
showDivider={vaultFiltered.length > 0}
{...optionProps}
highlightOffset={vaultFiltered.length}
/>
<CreateSection
show={showCreateOption}
query={query}
showDivider={allFiltered.length > 0}
highlighted={highlightIndex === allFiltered.length}
onSave={onSave}
onMouseEnter={() => setHighlightIndex(allFiltered.length)}
/>
{allFiltered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching statuses
</div>
)}
</div>
</div>
</>,
document.body
)}
</div>
)
}