From 177e593e9036b65ef3c8dcd44b12ed6944f45eb3 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Fri, 27 Feb 2026 19:00:30 +0100 Subject: [PATCH] fix: use portal-based positioning for property panel dropdowns (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * design: property dropdown narrow panel fix — before/after frames --------- Co-authored-by: Laputa App Co-authored-by: Claude Opus 4.6 --- .claude-done | 39 +-------- design/fix-property-dropdown-narrow.pen | 56 +++++++++++++ src/components/DynamicPropertiesPanel.tsx | 26 ++++-- src/components/StatusDropdown.tsx | 99 ++++++++++++++--------- 4 files changed, 140 insertions(+), 80 deletions(-) create mode 100644 design/fix-property-dropdown-narrow.pen diff --git a/.claude-done b/.claude-done index 3d76c91a..978da54b 100644 --- a/.claude-done +++ b/.claude-done @@ -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 diff --git a/design/fix-property-dropdown-narrow.pen b/design/fix-property-dropdown-narrow.pen new file mode 100644 index 00000000..deff115d --- /dev/null +++ b/design/fix-property-dropdown-narrow.pen @@ -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 + } + ] + } + ] +} diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index f9222cf2..556d5f5a 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -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(null) + const triggerRef = useRef(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 ( -
+
- {open && ( + {open && createPortal( <>
setOpen(false)} />
{DISPLAY_MODE_OPTIONS.map(opt => ( @@ -231,7 +246,8 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: { ))}
- + , + document.body )}
) diff --git a/src/components/StatusDropdown.tsx b/src/components/StatusDropdown.tsx index caafb5f2..dc3394a6 100644 --- a/src/components/StatusDropdown.tsx +++ b/src/components/StatusDropdown.tsx @@ -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(null) const inputRef = useRef(null) const listRef = useRef(null) + const anchorRef = useRef(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 ( -
-
-
-
- handleQueryChange(e.target.value)} - onKeyDown={handleKeyDown} - data-testid="status-search-input" - /> -
-
- - 0} - {...optionProps} - highlightOffset={vaultFiltered.length} - /> - 0} - highlighted={highlightIndex === allFiltered.length} - onSave={onSave} - onMouseEnter={() => setHighlightIndex(allFiltered.length)} - /> - {allFiltered.length === 0 && !showCreateOption && ( -
- No matching statuses +
+ {createPortal( + <> +
+
+
+ handleQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + data-testid="status-search-input" + />
- )} -
-
+
+ + 0} + {...optionProps} + highlightOffset={vaultFiltered.length} + /> + 0} + highlighted={highlightIndex === allFiltered.length} + onSave={onSave} + onMouseEnter={() => setHighlightIndex(allFiltered.length)} + /> + {allFiltered.length === 0 && !showCreateOption && ( +
+ No matching statuses +
+ )} +
+
+ , + document.body + )}
) }