refactor(ui): promote shared popover primitive
This commit is contained in:
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'
|
* 兼容层:通用 Popover 壳已提升到 `ui/Popover.tsx`(实现逐行迁移,DOM/行为不变)。
|
||||||
import { autoUpdate, flip, FloatingFocusManager, offset, shift, size, useFloating, type Padding, type Placement } from '@floating-ui/react'
|
* 这里只保留旧路径的两个导出名,不再保留任何实现,避免出现重复实现漂移。
|
||||||
import { sourceFloatingPlatform } from './SourceFloatingPlatform'
|
* 新代码请直接从 `../../ui/Popover` 引入 `Popover` / `PopoverTooltip`。
|
||||||
|
*/
|
||||||
|
export { Popover as SourceSelectPopover } from '../../ui/Popover'
|
||||||
export { RecoveredSelectTooltip as SourceSelectTooltip } from './RecoveredSelectTooltip'
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 { createPortal } from 'react-dom'
|
||||||
import type { PermissionMode } from '@shared/conversation'
|
import type { PermissionMode } from '@shared/conversation'
|
||||||
import { IconChevronSmall, IconClose } from '../../../components/icons'
|
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 { SelectCheckGlyph } from '../../../components/source-select/SourceSelectIcons'
|
||||||
import { sourceConversationLabel as t } from '../../conversation/source-labels'
|
import { sourceConversationLabel as t } from '../../conversation/source-labels'
|
||||||
import { ShieldDangerIcon, ShieldSecuredIcon, ShieldErrorIcon } from './SourcePermissionIcons'
|
import { ShieldDangerIcon, ShieldSecuredIcon, ShieldErrorIcon } from './SourcePermissionIcons'
|
||||||
|
|||||||
Reference in new issue
Block a user