Skip to content

Commit 174d468

Browse files
committed
Fix production UI popover issues
1 parent 88eede9 commit 174d468

9 files changed

Lines changed: 495 additions & 238 deletions

File tree

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react'
22
import { cn } from '../../cn'
3+
import { Popover, PopoverContent, PopoverTrigger } from './popover'
34

45
interface HoverPopoverProps {
56
trigger: React.ReactNode
@@ -22,39 +23,64 @@ export function HoverPopover({
2223
popoverClassName,
2324
openOnFocus = false,
2425
}: HoverPopoverProps) {
25-
const sideClass = side === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'
26-
const alignClass =
27-
align === 'start' ? 'left-0' :
28-
align === 'end' ? 'right-0' :
29-
'left-1/2 -translate-x-1/2'
26+
const [open, setOpen] = React.useState(false)
27+
const closeTimer = React.useRef<number | null>(null)
28+
29+
const clearCloseTimer = React.useCallback(() => {
30+
if (closeTimer.current != null) {
31+
window.clearTimeout(closeTimer.current)
32+
closeTimer.current = null
33+
}
34+
}, [])
35+
36+
const openPopover = React.useCallback(() => {
37+
clearCloseTimer()
38+
setOpen(true)
39+
}, [clearCloseTimer])
40+
41+
const closePopover = React.useCallback(() => {
42+
clearCloseTimer()
43+
closeTimer.current = window.setTimeout(() => setOpen(false), 80)
44+
}, [clearCloseTimer])
45+
46+
React.useEffect(() => {
47+
return () => clearCloseTimer()
48+
}, [clearCloseTimer])
3049

3150
return (
32-
<span className={cn('relative inline-flex group', className)}>
33-
{/* tabIndex: allow keyboard focus to open popover via focus-within (also helps on mobile tap) */}
34-
<span tabIndex={0} className="outline-none">
35-
{trigger}
36-
</span>
37-
<span
51+
<Popover open={open} onOpenChange={setOpen}>
52+
<PopoverTrigger asChild>
53+
<span
54+
className={cn('inline-flex', className)}
55+
tabIndex={openOnFocus ? 0 : undefined}
56+
onMouseEnter={openPopover}
57+
onMouseLeave={closePopover}
58+
onFocus={openOnFocus ? openPopover : undefined}
59+
onBlur={openOnFocus ? closePopover : undefined}
60+
>
61+
{trigger}
62+
</span>
63+
</PopoverTrigger>
64+
<PopoverContent
65+
side={side}
66+
align={align}
67+
sideOffset={8}
68+
onMouseEnter={openPopover}
69+
onMouseLeave={closePopover}
3870
className={cn(
39-
'absolute z-50',
40-
sideClass,
41-
alignClass,
42-
'opacity-0 pointer-events-none translate-y-1 scale-[0.98] transition-all duration-150',
43-
'group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-hover:scale-100',
44-
openOnFocus && 'group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0 group-focus-within:scale-100',
71+
'w-[22rem] max-w-[90vw] rounded-xl border border-border bg-card p-3 shadow-[0_16px_60px_rgba(0,0,0,0.18)]',
72+
popoverClassName,
4573
)}
4674
>
47-
<span className={cn('w-[22rem] max-w-[90vw] block rounded-xl bg-card border border-border shadow-[0_16px_60px_rgba(0,0,0,0.18)] p-3', popoverClassName)}>
48-
{title && (
49-
<div className="text-[12px] font-semibold text-foreground mb-1">
50-
{title}
51-
</div>
52-
)}
53-
<div className="text-[11px] leading-relaxed text-muted-foreground max-h-72 overflow-y-auto pr-1">
54-
{content}
75+
{title && (
76+
<div className="text-[12px] font-semibold text-foreground mb-1">
77+
{title}
5578
</div>
56-
</span>
57-
</span>
58-
</span>
79+
)}
80+
<div className="text-[11px] leading-relaxed text-muted-foreground max-h-72 overflow-y-auto pr-1">
81+
{content}
82+
</div>
83+
</PopoverContent>
84+
</Popover>
5985
)
6086
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { MouseEventHandler } from 'react'
2+
import { cn } from '@panwatch/base-ui'
3+
import { BadgeChip, type BadgeChipSize } from '@panwatch/biz-ui/components/badge-chip'
4+
import { resolveSuggestionColorClass, resolveSuggestionLabel } from '@panwatch/biz-ui/components/suggestion-action'
5+
6+
interface AiSuggestionBadgeProps {
7+
action?: string
8+
actionLabel?: string
9+
isAI?: boolean
10+
isExpired?: boolean
11+
size?: BadgeChipSize
12+
className?: string
13+
title?: string
14+
onClick?: MouseEventHandler<HTMLButtonElement>
15+
}
16+
17+
export function AiSuggestionBadge({
18+
action,
19+
actionLabel,
20+
isAI = false,
21+
isExpired = false,
22+
size = 'md',
23+
className,
24+
title,
25+
onClick,
26+
}: AiSuggestionBadgeProps) {
27+
const label = resolveSuggestionLabel(action, actionLabel)
28+
const colorClass = resolveSuggestionColorClass(action, actionLabel)
29+
return (
30+
<BadgeChip
31+
label={label}
32+
aiTag={isAI}
33+
size={size}
34+
title={title}
35+
onClick={onClick}
36+
className={cn(colorClass, isExpired && 'opacity-50', className)}
37+
/>
38+
)
39+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { MouseEventHandler, ReactNode } from 'react'
2+
import { cn } from '@panwatch/base-ui'
3+
4+
export type BadgeChipSize = 'xs' | 'sm' | 'md' | 'lg'
5+
6+
const sizeClassMap: Record<BadgeChipSize, string> = {
7+
xs: 'text-[10px] px-1.5 py-0.5',
8+
sm: 'text-[11px] px-2 py-0.5',
9+
md: 'text-[12px] px-2.5 py-1',
10+
lg: 'text-[13px] px-3 py-1.5',
11+
}
12+
13+
interface BadgeChipProps {
14+
label: ReactNode
15+
className?: string
16+
size?: BadgeChipSize
17+
aiTag?: boolean
18+
title?: string
19+
onClick?: MouseEventHandler<HTMLButtonElement>
20+
}
21+
22+
export function BadgeChip({
23+
label,
24+
className,
25+
size = 'md',
26+
aiTag = false,
27+
title,
28+
onClick,
29+
}: BadgeChipProps) {
30+
const sharedClass = cn(
31+
'relative inline-flex items-center rounded font-medium whitespace-nowrap transition-opacity',
32+
sizeClassMap[size],
33+
className,
34+
)
35+
36+
const aiTagNode = aiTag ? (
37+
<span className="pointer-events-none absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 text-[10px] leading-none px-1.5 py-[2px] rounded-sm bg-primary text-white uppercase shadow-sm ring-1 ring-black/20">
38+
AI
39+
</span>
40+
) : null
41+
42+
if (onClick) {
43+
return (
44+
<button type="button" onClick={onClick} title={title} className={cn(sharedClass, 'cursor-pointer hover:opacity-80')}>
45+
{label}
46+
{aiTagNode}
47+
</button>
48+
)
49+
}
50+
51+
return (
52+
<span title={title} className={sharedClass}>
53+
{label}
54+
{aiTagNode}
55+
</span>
56+
)
57+
}

frontend/packages/biz-ui/src/components/kline-indicators.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { HoverPopover } from '@panwatch/base-ui/components/ui/hover-popover'
22
import type { KlineSummaryData } from '@panwatch/biz-ui/components/kline-summary-dialog'
3+
import { TechnicalBadge } from '@panwatch/biz-ui/components/technical-badge'
34

45
interface KlineIndicatorsProps {
56
summary: KlineSummaryData
@@ -29,7 +30,7 @@ export function KlineIndicators({ summary: s }: KlineIndicatorsProps) {
2930
</div>
3031
</div>
3132
}
32-
trigger={<span className="px-2 py-0.5 rounded bg-accent/50 text-muted-foreground cursor-help hover:bg-accent/70">{s.trend}</span>}
33+
trigger={<TechnicalBadge label={s.trend} tone="neutral" help />}
3334
/>
3435
)}
3536

@@ -52,7 +53,7 @@ export function KlineIndicators({ summary: s }: KlineIndicatorsProps) {
5253
</div>
5354
</div>
5455
}
55-
trigger={<span className="px-2 py-0.5 rounded bg-accent/50 text-muted-foreground cursor-help hover:bg-accent/70">MACD {s.macd_status}</span>}
56+
trigger={<TechnicalBadge label={`MACD ${s.macd_status}`} tone="neutral" help />}
5657
/>
5758
)}
5859

@@ -75,51 +76,57 @@ export function KlineIndicators({ summary: s }: KlineIndicatorsProps) {
7576
</div>
7677
</div>
7778
}
78-
trigger={<span className={`px-2 py-0.5 rounded ${
79-
s.rsi_status === '超买' ? 'bg-rose-500/10 text-rose-600' :
80-
s.rsi_status === '超卖' ? 'bg-emerald-500/10 text-emerald-600' :
81-
'bg-accent/50 text-muted-foreground'
82-
} cursor-help hover:bg-accent/70`}>RSI {s.rsi_status}{s.rsi6 != null && ` (${s.rsi6.toFixed(0)})`}</span>}
79+
trigger={
80+
<TechnicalBadge
81+
label={`RSI ${s.rsi_status}${s.rsi6 != null ? ` (${s.rsi6.toFixed(0)})` : ''}`}
82+
tone={s.rsi_status === '超买' ? 'bullish' : s.rsi_status === '超卖' ? 'bearish' : 'neutral'}
83+
help
84+
/>
85+
}
8386
/>
8487
)}
8588

8689
{s.kdj_status && (
8790
<HoverPopover
8891
title="KDJ(转折/超买超卖)"
8992
content={<div>J 值更敏感,金叉/死叉用于观察短期转折,但容易受震荡干扰,需结合趋势与量价。</div>}
90-
trigger={<span className="px-2 py-0.5 rounded bg-accent/50 text-muted-foreground cursor-help hover:bg-accent/70">KDJ {s.kdj_status}</span>}
93+
trigger={<TechnicalBadge label={`KDJ ${s.kdj_status}`} tone="neutral" help />}
9194
/>
9295
)}
9396

9497
{s.volume_trend && (
9598
<HoverPopover
9699
title="量能(成交量配合)"
97100
content={<div>放量常用于确认突破或反弹有效性;缩量上冲/下跌容易“虚”。与趋势、关键位结合更可靠。</div>}
98-
trigger={<span className={`px-2 py-0.5 rounded ${
99-
s.volume_trend === '放量' ? 'bg-amber-500/10 text-amber-600' :
100-
s.volume_trend === '缩量' ? 'bg-blue-500/10 text-blue-600' :
101-
'bg-accent/50 text-muted-foreground'
102-
} cursor-help hover:bg-accent/70`}>{s.volume_trend}{s.volume_ratio != null && ` (${s.volume_ratio.toFixed(1)}x)`}</span>}
101+
trigger={
102+
<TechnicalBadge
103+
label={`${s.volume_trend}${s.volume_ratio != null ? ` (${s.volume_ratio.toFixed(1)}x)` : ''}`}
104+
tone={s.volume_trend === '放量' ? 'warning' : s.volume_trend === '缩量' ? 'info' : 'neutral'}
105+
help
106+
/>
107+
}
103108
/>
104109
)}
105110

106111
{s.boll_status && (
107112
<HoverPopover
108113
title="布林带(波动/偏离)"
109114
content={<div>上轨/下轨的突破/跌破常见于趋势阶段或极端波动。配合量能与回踩/站稳确认有效性。</div>}
110-
trigger={<span className={`px-2 py-0.5 rounded ${
111-
s.boll_status === '突破上轨' ? 'bg-rose-500/10 text-rose-600' :
112-
s.boll_status === '跌破下轨' ? 'bg-emerald-500/10 text-emerald-600' :
113-
'bg-accent/50 text-muted-foreground'
114-
} cursor-help hover:bg-accent/70`}>布林 {s.boll_status}</span>}
115+
trigger={
116+
<TechnicalBadge
117+
label={`布林 ${s.boll_status}`}
118+
tone={s.boll_status === '突破上轨' ? 'bullish' : s.boll_status === '跌破下轨' ? 'bearish' : 'neutral'}
119+
help
120+
/>
121+
}
115122
/>
116123
)}
117124

118125
{s.kline_pattern && (
119126
<HoverPopover
120127
title="K线形态(局部结构)"
121128
content={<div>单根形态提示意义有限,更看重所处位置(趋势/支撑压力附近)与量能配合。</div>}
122-
trigger={<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-600 cursor-help hover:bg-amber-500/15">{s.kline_pattern}</span>}
129+
trigger={<TechnicalBadge label={s.kline_pattern} tone="warning" help />}
123130
/>
124131
)}
125132
</div>
@@ -130,14 +137,14 @@ export function KlineIndicators({ summary: s }: KlineIndicatorsProps) {
130137
<HoverPopover
131138
title="支撑位(关键支撑区)"
132139
content={<div>接近支撑更容易止跌反弹;放量跌破可能转为压力。更偏向“区域”而非一点。</div>}
133-
trigger={<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-600 cursor-help hover:bg-emerald-500/15">支撑 {s.support.toFixed(2)}</span>}
140+
trigger={<TechnicalBadge label={`支撑 ${s.support.toFixed(2)}`} tone="bearish" help />}
134141
/>
135142
)}
136143
{s.resistance != null && (
137144
<HoverPopover
138145
title="压力位(关键压力区)"
139146
content={<div>越接近压力上行越难;放量突破并站稳后,原压力往往会角色互换变为支撑。</div>}
140-
trigger={<span className="px-2 py-0.5 rounded bg-rose-500/10 text-rose-600 cursor-help hover:bg-rose-500/15">压力 {s.resistance.toFixed(2)}</span>}
147+
trigger={<TechnicalBadge label={`压力 ${s.resistance.toFixed(2)}`} tone="bullish" help />}
141148
/>
142149
)}
143150
</div>

0 commit comments

Comments
 (0)