refactor: consolidate anchored dropdown positioning

This commit is contained in:
lucaronin
2026-05-27 16:08:24 +02:00
parent ba3f324a52
commit bbb30d6b91
9 changed files with 269 additions and 143 deletions

View File

@@ -737,6 +737,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Anchored dropdowns**: Fixed-position property menus and note-list sort menus use `src/components/anchoredDropdown.ts` for anchor measurement, viewport clamping, scroll/resize repositioning, and optional max-height calculations. Property-specific filtering and keyboard navigation stay in `propertyDropdownUtils.ts`.
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.

View File

@@ -1,5 +1,5 @@
import { ArrowUpRight, X as XIcon } from '@phosphor-icons/react'
import { useState, useCallback, useEffect, useRef, type ReactNode, type RefObject } from 'react'
import { useState, useCallback, useRef, type ReactNode, type RefObject } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
@@ -27,10 +27,12 @@ import { IconEditableValue } from './IconEditableValue'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
import { canonicalSystemMetadataKey } from '../utils/systemMetadata'
import { useDateDisplayFormat } from '../hooks/useAppPreferences'
import { getAnchoredDropdownStyle, useAnchoredDropdownPosition } from './anchoredDropdown'
const ISO_DATE_INPUT_RE = /^(\d{4})-(\d{2})-(\d{2})$/
const DEFAULT_DATE_PICKER_START_YEAR = 1800
const DEFAULT_DATE_PICKER_END_YEAR = 2200
const DISPLAY_MODE_MENU_WIDTH = 140
function localDate(year: number, monthIndex: number, day: number): Date {
const date = new Date(0)
@@ -479,35 +481,23 @@ function DisplayModeMenu({
onSelect: (mode: PropertyDisplayMode) => void
triggerRef: RefObject<HTMLButtonElement | null>
}) {
const backdropRef = useRef<HTMLDivElement>(null)
const positionMenu = useCallback((node: HTMLDivElement | null) => {
if (!node) return
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const menuW = 140
const left = Math.max(rect.right - menuW, 8)
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [triggerRef])
useEffect(() => {
const backdrop = backdropRef.current
if (!backdrop) return
backdrop.addEventListener('click', onClose)
return () => backdrop.removeEventListener('click', onClose)
}, [onClose])
const menuRef = useRef<HTMLDivElement>(null)
useAnchoredDropdownPosition({
anchorRef: triggerRef,
dropdownRef: menuRef,
width: DISPLAY_MODE_MENU_WIDTH,
})
return createPortal(
<>
<div
ref={backdropRef}
className="fixed inset-0 z-[12000]"
onClick={onClose}
/>
<div
ref={positionMenu}
ref={menuRef}
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
style={getAnchoredDropdownStyle(null, DISPLAY_MODE_MENU_WIDTH)}
data-testid="display-mode-menu"
>
{DISPLAY_MODE_OPTIONS.map((opt) => (

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS } from '../utils/noteListHelpers'
import { getAnchoredDropdownStyle, useAnchoredDropdownPosition } from './anchoredDropdown'
interface SortItem {
value: SortOption
@@ -27,12 +28,6 @@ type SortMenuAction =
| { type: 'close' }
| { type: 'focus'; index: number }
interface SortMenuPosition {
left: number
top: number
maxHeight: number
}
function getLocalizedSortOptionLabel(option: SortOption, locale: AppLocale): string {
if (option.startsWith('property:')) return option.slice('property:'.length)
return translate(locale, SORT_LABEL_KEYS_BY_OPTION.get(option) ?? 'noteList.sort.modified')
@@ -63,30 +58,6 @@ function focusSortItem(sortButtonRefs: React.MutableRefObject<Array<HTMLButtonEl
sortButtonRefs.current.at(index)?.focus()
}
function clampMenuPosition(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
function resolveSortMenuPosition(trigger: HTMLElement): SortMenuPosition {
const rect = trigger.getBoundingClientRect()
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
const maxLeft = Math.max(SORT_MENU_VIEWPORT_PADDING, viewportWidth - SORT_MENU_WIDTH - SORT_MENU_VIEWPORT_PADDING)
const left = clampMenuPosition(rect.right - SORT_MENU_WIDTH, SORT_MENU_VIEWPORT_PADDING, maxLeft)
const belowTop = rect.bottom + SORT_MENU_OFFSET
const availableBelow = viewportHeight - belowTop - SORT_MENU_VIEWPORT_PADDING
const availableAbove = rect.top - SORT_MENU_VIEWPORT_PADDING - SORT_MENU_OFFSET
const openAbove = availableBelow < SORT_MENU_MIN_HEIGHT && availableAbove > availableBelow
const availableHeight = openAbove ? availableAbove : availableBelow
const viewportBoundedMinHeight = Math.min(SORT_MENU_MIN_HEIGHT, Math.max(0, viewportHeight - (SORT_MENU_VIEWPORT_PADDING * 2)))
const maxHeight = Math.max(viewportBoundedMinHeight, Math.min(SORT_MENU_MAX_HEIGHT, availableHeight))
const top = openAbove
? Math.max(SORT_MENU_VIEWPORT_PADDING, rect.top - SORT_MENU_OFFSET - maxHeight)
: Math.min(belowTop, viewportHeight - SORT_MENU_VIEWPORT_PADDING - maxHeight)
return { left, top, maxHeight }
}
function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null {
const lastIndex = itemCount - 1
@@ -149,33 +120,6 @@ function useSortMenuDismissal({
}, [containerRef, menuRef, onDismiss, open])
}
function useSortMenuPosition({
open,
triggerRef,
}: {
open: boolean
triggerRef: React.RefObject<HTMLButtonElement | null>
}) {
const [menuPosition, setMenuPosition] = useState<SortMenuPosition | null>(null)
const updateMenuPosition = useCallback(() => {
if (!triggerRef.current) return
setMenuPosition(resolveSortMenuPosition(triggerRef.current))
}, [triggerRef])
useLayoutEffect(() => {
if (!open) return
window.addEventListener('resize', updateMenuPosition)
window.addEventListener('scroll', updateMenuPosition, true)
return () => {
window.removeEventListener('resize', updateMenuPosition)
window.removeEventListener('scroll', updateMenuPosition, true)
}
}, [open, updateMenuPosition])
return { menuPosition, updateMenuPosition }
}
function useSortDropdownState({
groupLabel,
current,
@@ -197,7 +141,16 @@ function useSortDropdownState({
triggerRef.current?.focus()
}, [])
const dismissMenu = useCallback(() => setOpen(false), [])
const { menuPosition, updateMenuPosition } = useSortMenuPosition({ open, triggerRef })
useAnchoredDropdownPosition({
open,
anchorRef: triggerRef,
dropdownRef: menuRef,
width: SORT_MENU_WIDTH,
maxHeight: SORT_MENU_MAX_HEIGHT,
minHeight: SORT_MENU_MIN_HEIGHT,
offset: SORT_MENU_OFFSET,
viewportPadding: SORT_MENU_VIEWPORT_PADDING,
})
useSortMenuDismissal({ open, containerRef, menuRef, onDismiss: dismissMenu })
@@ -233,9 +186,8 @@ function useSortDropdownState({
return
}
updateMenuPosition()
setOpen(true)
}, [open, updateMenuPosition])
}, [open])
return {
open,
@@ -244,7 +196,6 @@ function useSortDropdownState({
menuRef,
triggerRef,
sortButtonRefs,
menuPosition,
handleSelect,
handleMenuKeyDown,
}
@@ -298,7 +249,6 @@ function SortDropdownMenu({
sortItems,
sortButtonRefs,
menuRef,
menuPosition,
locale,
onKeyDown,
onSelect,
@@ -310,7 +260,6 @@ function SortDropdownMenu({
sortItems: SortItem[]
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
menuRef: React.RefObject<HTMLDivElement | null>
menuPosition: SortMenuPosition | null
locale: AppLocale
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelect: (option: SortOption, nextDirection: SortDirection) => void
@@ -327,12 +276,9 @@ function SortDropdownMenu({
aria-label={translate(locale, 'noteList.sort.menu', { label: groupLabel })}
className="fixed z-[12000] rounded-md border border-border bg-popover p-1 shadow-md"
style={{
left: menuPosition?.left ?? 0,
top: menuPosition?.top ?? 0,
width: SORT_MENU_WIDTH,
maxHeight: menuPosition?.maxHeight ?? SORT_MENU_MAX_HEIGHT,
...getAnchoredDropdownStyle(null, SORT_MENU_WIDTH),
maxHeight: SORT_MENU_MAX_HEIGHT,
overflowY: 'auto',
visibility: menuPosition ? 'visible' : 'hidden',
}}
onKeyDown={onKeyDown}
data-testid={`sort-menu-${groupLabel}`}
@@ -374,7 +320,6 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
menuRef,
triggerRef,
sortButtonRefs,
menuPosition,
handleSelect,
handleMenuKeyDown,
} = useSortDropdownState({
@@ -403,7 +348,6 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
sortItems={sortItems}
sortButtonRefs={sortButtonRefs}
menuRef={menuRef}
menuPosition={menuPosition}
locale={locale}
onKeyDown={handleMenuKeyDown}
onSelect={handleSelect}

View File

@@ -6,9 +6,9 @@ import {
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
useAnchoredDropdownPosition,
useAutoFocus,
} from './propertyDropdownUtils'
import { getAnchoredDropdownStyle, useAnchoredDropdownPosition } from './anchoredDropdown'
const PROPERTY_DROPDOWN_WIDTH = 208
const SELECTED_SWATCH_CHECK_STYLE = { color: 'var(--text-inverse)', fontSize: 8, lineHeight: 1 } as const
@@ -262,7 +262,12 @@ export function StatusDropdown({
const anchorRef = useRef<HTMLSpanElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH })
useAnchoredDropdownPosition({
anchorRef,
dropdownRef,
anchorElement: 'parent',
width: PROPERTY_DROPDOWN_WIDTH,
})
useAutoFocus(inputRef)
const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses)
@@ -313,6 +318,7 @@ export function StatusDropdown({
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
style={getAnchoredDropdownStyle(null, PROPERTY_DROPDOWN_WIDTH)}
data-testid="status-dropdown-popover"
>
<div className="border-b border-border px-2 py-1.5">

View File

@@ -5,10 +5,10 @@ import {
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
useAnchoredDropdownPosition,
useAutoFocus,
} from './propertyDropdownUtils'
import { AccentColorPicker } from './AccentColorPicker'
import { getAnchoredDropdownStyle, useAnchoredDropdownPosition } from './anchoredDropdown'
const PROPERTY_DROPDOWN_WIDTH = 208
@@ -211,7 +211,12 @@ export function TagsDropdown({
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags])
useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH })
useAnchoredDropdownPosition({
anchorRef,
dropdownRef,
anchorElement: 'parent',
width: PROPERTY_DROPDOWN_WIDTH,
})
useAutoFocus(inputRef)
const { filtered } = useTagFiltering(query, vaultTags)
@@ -252,6 +257,7 @@ export function TagsDropdown({
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
style={getAnchoredDropdownStyle(null, PROPERTY_DROPDOWN_WIDTH)}
data-testid="tags-dropdown-popover"
>
<div className="border-b border-border px-2 py-1.5">

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import {
getAnchoredDropdownLeft,
resolveAnchoredDropdownPosition,
} from './anchoredDropdown'
function makeRect({
left,
right,
top,
bottom,
}: {
left: number
right: number
top: number
bottom: number
}): DOMRectReadOnly {
return {
left,
right,
top,
bottom,
x: left,
y: top,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
}
}
describe('anchoredDropdown', () => {
it('keeps an anchored dropdown within the viewport margins', () => {
expect(getAnchoredDropdownLeft(300, 208, 800)).toBe(92)
expect(getAnchoredDropdownLeft(100, 208, 800)).toBe(8)
expect(getAnchoredDropdownLeft(900, 208, 800)).toBe(584)
})
it('keeps an over-wide dropdown at the visible left margin', () => {
expect(getAnchoredDropdownLeft(320, 400, 320, 8)).toBe(8)
})
it('opens below the anchor when there is enough viewport height', () => {
const position = resolveAnchoredDropdownPosition(
makeRect({ left: 130, right: 300, top: 80, bottom: 100 }),
{ width: 170, maxHeight: 280, minHeight: 160, offset: 4, viewportPadding: 8 },
{ width: 800, height: 600 },
)
expect(position).toEqual({ left: 130, top: 104, maxHeight: 280 })
})
it('opens above the anchor and clamps horizontally when there is not enough room below', () => {
const position = resolveAnchoredDropdownPosition(
makeRect({ left: 650, right: 820, top: 580, bottom: 604 }),
{ width: 170, maxHeight: 280, minHeight: 160, offset: 4, viewportPadding: 8 },
{ width: 800, height: 640 },
)
expect(position).toEqual({ left: 622, top: 296, maxHeight: 280 })
})
})

View File

@@ -0,0 +1,162 @@
import {
useCallback,
useLayoutEffect,
type CSSProperties,
type RefObject,
} from 'react'
const DEFAULT_DROPDOWN_MARGIN = 8
const DEFAULT_DROPDOWN_OFFSET = 4
export interface AnchoredDropdownPosition {
left: number
top: number
maxHeight?: number
}
export interface AnchoredDropdownViewport {
width: number
height: number
}
export interface AnchoredDropdownOptions {
width: number
maxHeight?: number
minHeight?: number
offset?: number
viewportPadding?: number
}
export type AnchoredDropdownAnchorElement = 'self' | 'parent'
export function getAnchoredDropdownLeft(
anchorRight: number,
dropdownWidth: number,
viewportWidth: number,
margin = DEFAULT_DROPDOWN_MARGIN,
) {
const rightAlignedLeft = anchorRight - dropdownWidth
const minLeft = margin
const maxLeft = viewportWidth - dropdownWidth - margin
if (maxLeft < minLeft) return minLeft
return Math.min(Math.max(rightAlignedLeft, minLeft), maxLeft)
}
function getViewport(): AnchoredDropdownViewport {
return {
width: window.innerWidth || document.documentElement.clientWidth,
height: window.innerHeight || document.documentElement.clientHeight,
}
}
export function resolveAnchoredDropdownPosition(
anchorRect: DOMRectReadOnly,
{
width,
maxHeight,
minHeight,
offset = DEFAULT_DROPDOWN_OFFSET,
viewportPadding = DEFAULT_DROPDOWN_MARGIN,
}: AnchoredDropdownOptions,
viewport = getViewport(),
): AnchoredDropdownPosition {
const left = getAnchoredDropdownLeft(anchorRect.right, width, viewport.width, viewportPadding)
const belowTop = anchorRect.bottom + offset
if (maxHeight === undefined) {
const maxTop = Math.max(viewportPadding, viewport.height - viewportPadding)
return { left, top: Math.min(Math.max(viewportPadding, belowTop), maxTop) }
}
const availableBelow = viewport.height - belowTop - viewportPadding
const availableAbove = anchorRect.top - viewportPadding - offset
const openAbove = minHeight !== undefined && availableBelow < minHeight && availableAbove > availableBelow
const availableHeight = openAbove ? availableAbove : availableBelow
const viewportBoundedMinHeight = Math.min(
minHeight ?? 0,
Math.max(0, viewport.height - (viewportPadding * 2)),
)
const resolvedMaxHeight = Math.max(viewportBoundedMinHeight, Math.min(maxHeight, availableHeight))
const top = openAbove
? Math.max(viewportPadding, anchorRect.top - offset - resolvedMaxHeight)
: Math.min(belowTop, viewport.height - viewportPadding - resolvedMaxHeight)
return { left, top, maxHeight: resolvedMaxHeight }
}
function getAnchorElement(
anchorRef: RefObject<HTMLElement | null>,
anchorElement: AnchoredDropdownAnchorElement,
) {
const current = anchorRef.current
if (!current) return null
return anchorElement === 'parent' ? current.parentElement : current
}
export function getAnchoredDropdownStyle(
position: AnchoredDropdownPosition | null,
width: number,
): CSSProperties {
return {
left: position?.left ?? 0,
top: position?.top ?? 0,
width,
maxHeight: position?.maxHeight,
visibility: position ? 'visible' : 'hidden',
}
}
export function useAnchoredDropdownPosition({
anchorRef,
dropdownRef,
anchorElement = 'self',
open = true,
width,
maxHeight,
minHeight,
offset,
viewportPadding,
}: AnchoredDropdownOptions & {
anchorRef: RefObject<HTMLElement | null>
dropdownRef: RefObject<HTMLElement | null>
anchorElement?: AnchoredDropdownAnchorElement
open?: boolean
}) {
const updatePosition = useCallback(() => {
const anchor = getAnchorElement(anchorRef, anchorElement)
const dropdown = dropdownRef.current
if (!anchor || !dropdown) return
const position = resolveAnchoredDropdownPosition(anchor.getBoundingClientRect(), {
width,
maxHeight,
minHeight,
offset,
viewportPadding,
})
dropdown.style.left = `${position.left}px`
dropdown.style.top = `${position.top}px`
dropdown.style.width = `${width}px`
dropdown.style.visibility = 'visible'
if (position.maxHeight === undefined) {
dropdown.style.removeProperty('max-height')
} else {
dropdown.style.maxHeight = `${position.maxHeight}px`
}
}, [anchorElement, anchorRef, dropdownRef, maxHeight, minHeight, offset, viewportPadding, width])
useLayoutEffect(() => {
if (!open) return
updatePosition()
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
return () => {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}, [open, updatePosition])
return { updatePosition }
}

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
getAnchoredDropdownLeft,
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
@@ -19,14 +18,4 @@ describe('propertyDropdownUtils', () => {
expect(isCreateOptionVisible(' draft ', ['Draft'])).toBe(false)
expect(isCreateOptionVisible('Review', ['Draft'])).toBe(true)
})
it('keeps an anchored dropdown within the viewport margins', () => {
expect(getAnchoredDropdownLeft(300, 208, 800)).toBe(92)
expect(getAnchoredDropdownLeft(100, 208, 800)).toBe(8)
expect(getAnchoredDropdownLeft(900, 208, 800)).toBe(584)
})
it('keeps an over-wide dropdown at the visible left margin', () => {
expect(getAnchoredDropdownLeft(320, 400, 320, 8)).toBe(8)
})
})

View File

@@ -1,19 +1,6 @@
import { useEffect, useLayoutEffect, type RefObject } from 'react'
import { useEffect, type RefObject } from 'react'
const DEFAULT_DROPDOWN_MARGIN = 8
export function getAnchoredDropdownLeft(
anchorRight: number,
dropdownWidth: number,
viewportWidth: number,
margin = DEFAULT_DROPDOWN_MARGIN,
) {
const rightAlignedLeft = anchorRight - dropdownWidth
const minLeft = margin
const maxLeft = viewportWidth - dropdownWidth - margin
if (maxLeft < minLeft) return minLeft
return Math.min(Math.max(rightAlignedLeft, minLeft), maxLeft)
}
export { getAnchoredDropdownLeft } from './anchoredDropdown'
export function getNextHighlightIndex(current: number, total: number) {
if (total <= 0) return 0
@@ -31,26 +18,6 @@ export function isCreateOptionVisible(query: string, options: string[]) {
return !options.some((option) => option.toLowerCase() === trimmed.toLowerCase())
}
export function useAnchoredDropdownPosition({
anchorRef,
dropdownRef,
width,
}: {
anchorRef: RefObject<HTMLElement | null>
dropdownRef: RefObject<HTMLElement | null>
width: number
}) {
useLayoutEffect(() => {
const node = dropdownRef.current
const anchor = anchorRef.current?.parentElement
if (!node || !anchor) return
const rect = anchor.getBoundingClientRect()
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${getAnchoredDropdownLeft(rect.right, width, window.innerWidth)}px`
}, [anchorRef, dropdownRef, width])
}
export function useAutoFocus<T extends HTMLElement>(ref: RefObject<T | null>) {
useEffect(() => {
ref.current?.focus()