refactor(ui): promote shared popover primitive

This commit is contained in:
xiaohan.yuan committed 2026-10-18 19:33:26 +08:00
1 parent 48d64a24bf
commit 0fb2226f8c
3 files changed
+91 -75

No files matched your search

@@ -1,75 +1,7 @@
import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { autoUpdate, flip, FloatingFocusManager, offset, shift, size, useFloating, type Padding, type Placement } from '@floating-ui/react'
import { sourceFloatingPlatform } from './SourceFloatingPlatform'
/**
* 兼容层:通用 Popover 壳已提升到 `ui/Popover.tsx`(实现逐行迁移,DOM/行为不变)。
* 这里只保留旧路径的两个导出名,不再保留任何实现,避免出现重复实现漂移。
* 新代码请直接从 `../../ui/Popover` 引入 `Popover` / `PopoverTooltip`。
*/
export { Popover as SourceSelectPopover } from '../../ui/Popover'
export { RecoveredSelectTooltip as SourceSelectTooltip } from './RecoveredSelectTooltip'
interface PopoverProps {
trigger: ReactElement<any>; children: ReactNode; open: boolean; onOpenChange(open: boolean): void
disabled?: boolean; placement?: Placement; portalRoot?: 'inline' | 'body' | HTMLElement | (() => HTMLElement | null)
className?: string; role?: string; ariaLabel?: string; manageFocus?: boolean; initialFocus?: number
matchReferenceWidth?: boolean; viewportPadding?: Padding; offset?: number; transitionPreset?: 'expand' | 'fade'
}
// Host adaptation of the original PopoverLayer: identical middleware order, inline strategy,
// DOM classes, source transition states and clipping. Floating UI's original version is unknown.
export function SourceSelectPopover(props: PopoverProps): React.JSX.Element {
const { trigger, open, disabled, onOpenChange, children, placement = 'bottom', portalRoot = 'inline', className = '', role = 'dialog', manageFocus = false, initialFocus, matchReferenceWidth, viewportPadding = 8, transitionPreset = 'fade' } = props
const anchor = useRef<HTMLElement | null>(null), previous = useRef(open), id = 'wb-popover' + useId()
const [exiting, setExiting] = useState(false)
useEffect(() => {
const wasOpen = previous.current; previous.current = open
if (open) { setExiting(false); return }
if (!wasOpen || transitionPreset !== 'expand') return
setExiting(true)
const timer = window.setTimeout(() => setExiting(false), window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 150)
return () => clearTimeout(timer)
}, [open, transitionPreset])
const close = useCallback(() => onOpenChange(false), [onOpenChange])
const mergedTrigger = cloneElement(trigger, {
ref: (element: HTMLElement | null) => {
anchor.current = element
const ref = trigger.props.ref
if (typeof ref === 'function') ref(element); else if (ref) ref.current = element
}, role: role === 'listbox' ? 'combobox' : trigger.props.role,
'aria-label': props.ariaLabel ?? trigger.props['aria-label'],
'aria-haspopup': role, 'aria-expanded': open ? 'true' : 'false', 'aria-controls': open ? id : undefined,
onClick: (event: React.MouseEvent) => { trigger.props.onClick?.(event); if (!event.defaultPrevented && !disabled) onOpenChange(!open) }
})
return <>{mergedTrigger}{(open || exiting) && <PopoverLayer {...props} anchor={anchor} close={close} id={id} exiting={exiting} placement={placement} portalRoot={portalRoot} className={className} role={role} manageFocus={manageFocus} initialFocus={initialFocus} matchReferenceWidth={matchReferenceWidth} viewportPadding={viewportPadding} transitionPreset={transitionPreset}>{children}</PopoverLayer>}</>
}
function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, className, role, manageFocus, initialFocus, matchReferenceWidth, viewportPadding, offset: offsetDistance = 8, transitionPreset, children, ariaLabel }: PopoverProps & { anchor: React.RefObject<HTMLElement | null>; close(): void; id: string; exiting: boolean }): React.JSX.Element {
const inline = portalRoot === 'inline'
const padding = typeof viewportPadding === 'number' ? viewportPadding : { top: 8, right: 8, bottom: 8, left: 8, ...viewportPadding }
const { refs, floatingStyles, context, placement: actual, isPositioned } = useFloating({
open: true, onOpenChange: next => { if (!next && !exiting) close() }, placement,
middleware: [offset(offsetDistance), flip(), shift({ padding }), ...(matchReferenceWidth ? [size({ padding, apply({ elements, rects, availableHeight }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px`, maxHeight: `${Math.max(0, availableHeight)}px` }) } })] : [])],
whileElementsMounted: autoUpdate, strategy: inline ? 'absolute' : 'fixed', transform: false, platform: sourceFloatingPlatform
})
useLayoutEffect(() => { refs.setReference(anchor.current) }, [refs, anchor])
useEffect(() => {
if (exiting) return
const outside = (event: PointerEvent): void => {
if (event.button !== 0 || !(event.target instanceof Node)) return
if (!refs.floating.current?.contains(event.target) && !anchor.current?.contains(event.target)) close()
}
const escape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
event.preventDefault(); event.stopPropagation(); close(); anchor.current?.focus({ preventScroll: true })
}
document.addEventListener('pointerdown', outside)
// Capture prevents the model editor's document Escape listener from also closing the editor.
document.addEventListener('keydown', escape, true)
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) }
}, [anchor, close, exiting, refs.floating])
useEffect(() => { if (refs.floating.current) refs.floating.current.inert = exiting }, [exiting, refs.floating])
const [side, alignment] = actual.split('-'), expand = transitionPreset === 'expand'
const origin = side === 'bottom' || side === 'top' ? `${alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'} ${side === 'bottom' ? 'top' : 'bottom'}` : `${side === 'left' ? 'right' : 'left'} ${alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'}`
const element = <div ref={refs.setFloating} id={id} role={role} aria-label={ariaLabel} aria-hidden={exiting || undefined} tabIndex={manageFocus && !exiting ? -1 : undefined}
className={['wb-popover', 'wb-popover--placement-' + side, 'cb-font-size-capped', expand ? 'wb-popover--expand-' + (exiting ? 'close' : isPositioned ? 'open' : 'initial') : '', className].filter(Boolean).join(' ')}
style={{ ...floatingStyles, zIndex: 1100, visibility: isPositioned || exiting ? 'visible' : 'hidden', ...(expand ? { transformOrigin: origin } : {}) }}>{children}</div>
const managed = manageFocus ? <FloatingFocusManager context={context} modal={false} initialFocus={initialFocus ?? refs.floating}>{element}</FloatingFocusManager> : element
if (inline) return managed
const target = typeof portalRoot === 'function' ? portalRoot() : portalRoot instanceof HTMLElement ? portalRoot : document.body
return createPortal(<span className="source-select-host">{managed}</span>, target ?? document.body)
}
+84
View File
@@ -0,0 +1,84 @@
/**
* YCode UI Popover / PopoverTooltip(P4)。
*
* 实现逐行迁移自 `components/source-select/SourceSelectPopover.tsx` 的 REA 适配壳,
* 只改文件位置与导出名:DOM class、floating middleware 顺序、inline/portal 策略、
* focus/Escape/outside pointer、transitionPreset 与 aria 合并逻辑一律未改动。
* 迁移保留的既有字面量(数值已存在,token 无法立即表达,本次不发明 token、不改行为):
* viewportPadding/offset 默认 8、退出动画 150(reduced-motion 1)、zIndex 1100,
* 以及按浮层测量结果写入的行内尺寸。
*/
import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { autoUpdate, flip, FloatingFocusManager, offset, shift, size, useFloating, type Padding, type Placement } from '@floating-ui/react'
import { sourceFloatingPlatform } from '../components/source-select/SourceFloatingPlatform'
export { RecoveredSelectTooltip as PopoverTooltip } from '../components/source-select/RecoveredSelectTooltip'
export interface PopoverProps {
trigger: ReactElement<any>; children: ReactNode; open: boolean; onOpenChange(open: boolean): void
disabled?: boolean; placement?: Placement; portalRoot?: 'inline' | 'body' | HTMLElement | (() => HTMLElement | null)
className?: string; role?: string; ariaLabel?: string; manageFocus?: boolean; initialFocus?: number
matchReferenceWidth?: boolean; viewportPadding?: Padding; offset?: number; transitionPreset?: 'expand' | 'fade'
}
// Host adaptation of the original PopoverLayer: identical middleware order, inline strategy,
// DOM classes, source transition states and clipping. Floating UI's original version is unknown.
export function Popover(props: PopoverProps): React.JSX.Element {
const { trigger, open, disabled, onOpenChange, children, placement = 'bottom', portalRoot = 'inline', className = '', role = 'dialog', manageFocus = false, initialFocus, matchReferenceWidth, viewportPadding = 8, transitionPreset = 'fade' } = props
const anchor = useRef<HTMLElement | null>(null), previous = useRef(open), id = 'wb-popover' + useId()
const [exiting, setExiting] = useState(false)
useEffect(() => {
const wasOpen = previous.current; previous.current = open
if (open) { setExiting(false); return }
if (!wasOpen || transitionPreset !== 'expand') return
setExiting(true)
const timer = window.setTimeout(() => setExiting(false), window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 150)
return () => clearTimeout(timer)
}, [open, transitionPreset])
const close = useCallback(() => onOpenChange(false), [onOpenChange])
const mergedTrigger = cloneElement(trigger, {
ref: (element: HTMLElement | null) => {
anchor.current = element
const ref = trigger.props.ref
if (typeof ref === 'function') ref(element); else if (ref) ref.current = element
}, role: role === 'listbox' ? 'combobox' : trigger.props.role,
'aria-label': props.ariaLabel ?? trigger.props['aria-label'],
'aria-haspopup': role, 'aria-expanded': open ? 'true' : 'false', 'aria-controls': open ? id : undefined,
onClick: (event: React.MouseEvent) => { trigger.props.onClick?.(event); if (!event.defaultPrevented && !disabled) onOpenChange(!open) }
})
return <>{mergedTrigger}{(open || exiting) && <PopoverLayer {...props} anchor={anchor} close={close} id={id} exiting={exiting} placement={placement} portalRoot={portalRoot} className={className} role={role} manageFocus={manageFocus} initialFocus={initialFocus} matchReferenceWidth={matchReferenceWidth} viewportPadding={viewportPadding} transitionPreset={transitionPreset}>{children}</PopoverLayer>}</>
}
function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, className, role, manageFocus, initialFocus, matchReferenceWidth, viewportPadding, offset: offsetDistance = 8, transitionPreset, children, ariaLabel }: PopoverProps & { anchor: React.RefObject<HTMLElement | null>; close(): void; id: string; exiting: boolean }): React.JSX.Element {
const inline = portalRoot === 'inline'
const padding = typeof viewportPadding === 'number' ? viewportPadding : { top: 8, right: 8, bottom: 8, left: 8, ...viewportPadding }
const { refs, floatingStyles, context, placement: actual, isPositioned } = useFloating({
open: true, onOpenChange: next => { if (!next && !exiting) close() }, placement,
middleware: [offset(offsetDistance), flip(), shift({ padding }), ...(matchReferenceWidth ? [size({ padding, apply({ elements, rects, availableHeight }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px`, maxHeight: `${Math.max(0, availableHeight)}px` }) } })] : [])],
whileElementsMounted: autoUpdate, strategy: inline ? 'absolute' : 'fixed', transform: false, platform: sourceFloatingPlatform
})
useLayoutEffect(() => { refs.setReference(anchor.current) }, [refs, anchor])
useEffect(() => {
if (exiting) return
const outside = (event: PointerEvent): void => {
if (event.button !== 0 || !(event.target instanceof Node)) return
if (!refs.floating.current?.contains(event.target) && !anchor.current?.contains(event.target)) close()
}
const escape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
event.preventDefault(); event.stopPropagation(); close(); anchor.current?.focus({ preventScroll: true })
}
document.addEventListener('pointerdown', outside)
// Capture prevents the model editor's document Escape listener from also closing the editor.
document.addEventListener('keydown', escape, true)
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) }
}, [anchor, close, exiting, refs.floating])
useEffect(() => { if (refs.floating.current) refs.floating.current.inert = exiting }, [exiting, refs.floating])
const [side, alignment] = actual.split('-'), expand = transitionPreset === 'expand'
const origin = side === 'bottom' || side === 'top' ? `${alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'} ${side === 'bottom' ? 'top' : 'bottom'}` : `${side === 'left' ? 'right' : 'left'} ${alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'}`
const element = <div ref={refs.setFloating} id={id} role={role} aria-label={ariaLabel} aria-hidden={exiting || undefined} tabIndex={manageFocus && !exiting ? -1 : undefined}
className={['wb-popover', 'wb-popover--placement-' + side, 'cb-font-size-capped', expand ? 'wb-popover--expand-' + (exiting ? 'close' : isPositioned ? 'open' : 'initial') : '', className].filter(Boolean).join(' ')}
style={{ ...floatingStyles, zIndex: 1100, visibility: isPositioned || exiting ? 'visible' : 'hidden', ...(expand ? { transformOrigin: origin } : {}) }}>{children}</div>
const managed = manageFocus ? <FloatingFocusManager context={context} modal={false} initialFocus={initialFocus ?? refs.floating}>{element}</FloatingFocusManager> : element
if (inline) return managed
const target = typeof portalRoot === 'function' ? portalRoot() : portalRoot instanceof HTMLElement ? portalRoot : document.body
return createPortal(<span className="source-select-host">{managed}</span>, target ?? document.body)
}
@@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState, type ComponentType, type SVGProps }
import { createPortal } from 'react-dom'
import type { PermissionMode } from '@shared/conversation'
import { IconChevronSmall, IconClose } from '../../../components/icons'
import { SourceSelectPopover, SourceSelectTooltip } from '../../../components/source-select/SourceSelectPopover'
import { Popover as SourceSelectPopover, PopoverTooltip as SourceSelectTooltip } from '../../../ui/Popover'
import { SelectCheckGlyph } from '../../../components/source-select/SourceSelectIcons'
import { sourceConversationLabel as t } from '../../conversation/source-labels'
import { ShieldDangerIcon, ShieldSecuredIcon, ShieldErrorIcon } from './SourcePermissionIcons'