diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index d87bb9a9..970b5b69 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/src/components/PropertyValueCells.tsx b/src/components/PropertyValueCells.tsx index 5ea741ef..31aacfa3 100644 --- a/src/components/PropertyValueCells.tsx +++ b/src/components/PropertyValueCells.tsx @@ -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 }) { - const backdropRef = 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 - 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(null) + useAnchoredDropdownPosition({ + anchorRef: triggerRef, + dropdownRef: menuRef, + width: DISPLAY_MODE_MENU_WIDTH, + }) return createPortal( <>
{DISPLAY_MODE_OPTIONS.map((opt) => ( diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index 0dbe4a73..f229cca2 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -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 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 -}) { - const [menuPosition, setMenuPosition] = useState(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> menuRef: React.RefObject - menuPosition: SortMenuPosition | null locale: AppLocale onKeyDown: (event: React.KeyboardEvent) => 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} diff --git a/src/components/StatusDropdown.tsx b/src/components/StatusDropdown.tsx index 16df13ce..9d338821 100644 --- a/src/components/StatusDropdown.tsx +++ b/src/components/StatusDropdown.tsx @@ -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(null) const dropdownRef = useRef(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({
diff --git a/src/components/TagsDropdown.tsx b/src/components/TagsDropdown.tsx index bc16b189..3c464912 100644 --- a/src/components/TagsDropdown.tsx +++ b/src/components/TagsDropdown.tsx @@ -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({
diff --git a/src/components/anchoredDropdown.test.ts b/src/components/anchoredDropdown.test.ts new file mode 100644 index 00000000..162ba9a4 --- /dev/null +++ b/src/components/anchoredDropdown.test.ts @@ -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 }) + }) +}) diff --git a/src/components/anchoredDropdown.ts b/src/components/anchoredDropdown.ts new file mode 100644 index 00000000..1b3c2862 --- /dev/null +++ b/src/components/anchoredDropdown.ts @@ -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, + 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 + dropdownRef: RefObject + 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 } +} diff --git a/src/components/propertyDropdownUtils.test.ts b/src/components/propertyDropdownUtils.test.ts index c8b1fa76..81a6864c 100644 --- a/src/components/propertyDropdownUtils.test.ts +++ b/src/components/propertyDropdownUtils.test.ts @@ -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) - }) }) diff --git a/src/components/propertyDropdownUtils.ts b/src/components/propertyDropdownUtils.ts index 84cbbd6d..1fcf18e0 100644 --- a/src/components/propertyDropdownUtils.ts +++ b/src/components/propertyDropdownUtils.ts @@ -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 - dropdownRef: RefObject - 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(ref: RefObject) { useEffect(() => { ref.current?.focus()