refactor: consolidate anchored dropdown positioning
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
61
src/components/anchoredDropdown.test.ts
Normal file
61
src/components/anchoredDropdown.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
162
src/components/anchoredDropdown.ts
Normal file
162
src/components/anchoredDropdown.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user