ChartHighlightActions.tsx141 lines · main
1import dayjs from 'dayjs'
2import { ArrowRight, SearchIcon } from 'lucide-react'
3import { ReactNode, useEffect, useMemo, useState } from 'react'
4import {
5 cn,
6 DropdownMenu,
7 DropdownMenuContent,
8 DropdownMenuItem,
9 DropdownMenuLabel,
10 DropdownMenuSeparator,
11 DropdownMenuTrigger,
12} from 'ui'
13
14import { ChartHighlight } from './useChartHighlight'
15import { useFormatDateTime } from '@/lib/datetime'
16
17export type UpdateDateRange = (from: string, to: string) => void
18
19export type ChartHighlightActionContext = {
20 start: string
21 end: string
22 clear: () => void
23 chartId?: string
24}
25
26export type ChartHighlightAction = {
27 id: string
28 label: string | ((ctx: ChartHighlightActionContext) => string)
29 icon?: ReactNode
30 isDisabled?: (ctx: ChartHighlightActionContext) => boolean
31 rightSlot?: ReactNode | ((ctx: ChartHighlightActionContext) => ReactNode)
32 onSelect: (ctx: ChartHighlightActionContext) => void
33}
34
35export const ChartHighlightActions = ({
36 chartHighlight,
37 updateDateRange,
38 actions,
39 chartId,
40}: {
41 chartHighlight?: ChartHighlight
42 updateDateRange?: UpdateDateRange
43 actions?: ChartHighlightAction[]
44 chartId?: string
45}) => {
46 const { left: selectedRangeStart, right: selectedRangeEnd, clearHighlight } = chartHighlight ?? {}
47 const [isOpen, setIsOpen] = useState(!!chartHighlight?.popoverPosition)
48 const formatChartDate = useFormatDateTime()
49
50 useEffect(() => {
51 setIsOpen(!!chartHighlight?.popoverPosition && selectedRangeStart !== selectedRangeEnd)
52 }, [chartHighlight?.popoverPosition])
53
54 const ctx: ChartHighlightActionContext | undefined =
55 selectedRangeStart && selectedRangeEnd && clearHighlight
56 ? { start: selectedRangeStart, end: selectedRangeEnd, clear: clearHighlight, chartId }
57 : undefined
58
59 const defaultActions: ChartHighlightAction[] = useMemo(() => {
60 if (!updateDateRange || !ctx) return []
61 const isDisabled = dayjs(ctx.end).diff(dayjs(ctx.start), 'minutes') < 10
62 return [
63 {
64 id: 'zoom-in',
65 label: 'Zoom in',
66 icon: <SearchIcon className="text-foreground-lighter" size={12} />,
67 rightSlot: isDisabled ? <span className="text-xs">Min. 10 minutes</span> : null,
68 isDisabled: () => isDisabled,
69 onSelect: ({ start, end, clear }) => {
70 if (isDisabled) return
71 updateDateRange(start, end)
72 clear()
73 },
74 },
75 ]
76 }, [ctx, updateDateRange])
77
78 const allActions: ChartHighlightAction[] = useMemo(() => {
79 const provided = actions ?? []
80 return [...defaultActions, ...provided]
81 }, [defaultActions, actions])
82
83 return (
84 <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
85 <DropdownMenuTrigger
86 className="w-auto p-0"
87 style={{
88 position: 'absolute',
89 left: chartHighlight?.popoverPosition?.x + 'px' || 0,
90 top: chartHighlight?.popoverPosition?.y + 'px' || 0,
91 }}
92 />
93 <DropdownMenuContent
94 className="flex flex-col gap-1 p-1 w-fit text-left"
95 onEscapeKeyDown={() => clearHighlight?.()}
96 onInteractOutside={(e) => {
97 const target = e.target as Element | null
98 // If the user clicked on a chart, handleMouseDown will manage the new selection.
99 // Calling clearHighlight here would race with it and clobber the new state.
100 if (target?.closest('.recharts-wrapper')) return
101 clearHighlight?.()
102 }}
103 >
104 <DropdownMenuLabel className="flex items-center justify-center text-foreground-light font-mono gap-x-2 text-xs">
105 <span>{formatChartDate(selectedRangeStart!, 'MMM D, H:mm')}</span>
106 <ArrowRight size={10} />
107 <span>{formatChartDate(selectedRangeEnd!, 'MMM D, H:mm')}</span>
108 </DropdownMenuLabel>
109 <DropdownMenuSeparator className="my-0" />
110 {allActions.map((action) => {
111 const disabled = ctx && action.isDisabled ? action.isDisabled(ctx) : false
112 let labelNode: ReactNode = null
113 if (typeof action.label === 'function') {
114 labelNode = ctx ? action.label(ctx) : null
115 } else {
116 labelNode = action.label
117 }
118 let rightNode: ReactNode = null
119 if (typeof action.rightSlot === 'function') {
120 rightNode = ctx ? action.rightSlot(ctx) : null
121 } else {
122 rightNode = action.rightSlot ?? null
123 }
124 return (
125 <DropdownMenuItem asChild key={action.id} disabled={disabled} className={cn('group')}>
126 <button
127 disabled={disabled}
128 onClick={() => ctx && action.onSelect({ ...ctx })}
129 className="w-full flex items-center gap-1.5"
130 >
131 {action.icon}
132 <span className="grow text-left">{labelNode}</span>
133 {rightNode}
134 </button>
135 </DropdownMenuItem>
136 )
137 })}
138 </DropdownMenuContent>
139 </DropdownMenu>
140 )
141}