ChartHighlightActions.tsx141 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import { ArrowRight, SearchIcon } from 'lucide-react' |
| 3 | import { ReactNode, useEffect, useMemo, useState } from 'react' |
| 4 | import { |
| 5 | cn, |
| 6 | DropdownMenu, |
| 7 | DropdownMenuContent, |
| 8 | DropdownMenuItem, |
| 9 | DropdownMenuLabel, |
| 10 | DropdownMenuSeparator, |
| 11 | DropdownMenuTrigger, |
| 12 | } from 'ui' |
| 13 | |
| 14 | import { ChartHighlight } from './useChartHighlight' |
| 15 | import { useFormatDateTime } from '@/lib/datetime' |
| 16 | |
| 17 | export type UpdateDateRange = (from: string, to: string) => void |
| 18 | |
| 19 | export type ChartHighlightActionContext = { |
| 20 | start: string |
| 21 | end: string |
| 22 | clear: () => void |
| 23 | chartId?: string |
| 24 | } |
| 25 | |
| 26 | export 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 | |
| 35 | export 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 | } |