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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user