@orsetra/shared-ui 1.10.16 → 1.10.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/components/ui/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ export { Popover, PopoverTrigger, PopoverContent } from './popover'
|
|
|
49
49
|
export { Progress } from './progress'
|
|
50
50
|
export { RadioGroup, RadioGroupItem } from './radio-group'
|
|
51
51
|
export { RelativeTimeRangePicker, type RelativeTimeRangeOption, type RelativeTimeRangePickerProps } from './relative-time-range-picker'
|
|
52
|
+
export { TimeRangePicker, resolveTimeRange, type TimeRangeValue, type TimeRangePickerProps } from './time-range-picker'
|
|
52
53
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './resizable'
|
|
53
54
|
export { ScrollArea, ScrollBar } from './scroll-area'
|
|
54
55
|
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator } from './select'
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Check, ChevronDown, Clock } from "lucide-react"
|
|
5
|
+
import type { DateRange } from "react-day-picker"
|
|
6
|
+
import { cn } from "../../lib/utils"
|
|
7
|
+
import { Button } from "./button"
|
|
8
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
|
9
|
+
import { Calendar } from "./calendar"
|
|
10
|
+
import { Input } from "./input"
|
|
11
|
+
import type { RelativeTimeRangeOption } from "./relative-time-range-picker"
|
|
12
|
+
|
|
13
|
+
export type { RelativeTimeRangeOption }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A relative window ("last N seconds", resolved against "now" on every fetch/refresh) or a
|
|
17
|
+
* fixed absolute one (a specific start/end, in epoch seconds — doesn't move on refresh).
|
|
18
|
+
*/
|
|
19
|
+
export type TimeRangeValue =
|
|
20
|
+
| { mode: "relative"; seconds: number }
|
|
21
|
+
| { mode: "absolute"; from: number; to: number }
|
|
22
|
+
|
|
23
|
+
/** Turns either shape into the `{start, end}` window callers actually query with. */
|
|
24
|
+
export function resolveTimeRange(value: TimeRangeValue): { start: number; end: number } {
|
|
25
|
+
if (value.mode === "absolute") {
|
|
26
|
+
return { start: Math.min(value.from, value.to), end: Math.max(value.from, value.to) }
|
|
27
|
+
}
|
|
28
|
+
const end = Math.floor(Date.now() / 1000)
|
|
29
|
+
return { start: end - value.seconds, end }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TimeRangePickerProps {
|
|
33
|
+
id?: string
|
|
34
|
+
value: TimeRangeValue
|
|
35
|
+
onChange: (value: TimeRangeValue) => void
|
|
36
|
+
options: RelativeTimeRangeOption[]
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
className?: string
|
|
39
|
+
/** Popover alignment relative to the trigger. Defaults to "end" (picker anchored right — the
|
|
40
|
+
* common placement next to a panel's close button). */
|
|
41
|
+
align?: "start" | "center" | "end"
|
|
42
|
+
/** Accessible name and popover heading. Defaults to "Time range". */
|
|
43
|
+
triggerLabel?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DAY_SECONDS = 24 * 60 * 60
|
|
47
|
+
|
|
48
|
+
// Refreshed periodically (see the interval effect below) so a relative selection's label
|
|
49
|
+
// doesn't drift far from reality if the picker is left open for a while without changing.
|
|
50
|
+
const REFRESH_INTERVAL_MS = 30_000
|
|
51
|
+
|
|
52
|
+
const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })
|
|
53
|
+
const dayFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" })
|
|
54
|
+
|
|
55
|
+
function formatSince(sinceMs: number, isDayScale: boolean): string {
|
|
56
|
+
const date = new Date(sinceMs)
|
|
57
|
+
const time = timeFormatter.format(date)
|
|
58
|
+
return isDayScale ? `Since ${dayFormatter.format(date)}, ${time}` : `Since ${time}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatAbsolute(fromSec: number, toSec: number): string {
|
|
62
|
+
const from = new Date(fromSec * 1000)
|
|
63
|
+
const to = new Date(toSec * 1000)
|
|
64
|
+
const sameDay = from.toDateString() === to.toDateString()
|
|
65
|
+
const fromStr = `${dayFormatter.format(from)}, ${timeFormatter.format(from)}`
|
|
66
|
+
const toStr = sameDay ? timeFormatter.format(to) : `${dayFormatter.format(to)}, ${timeFormatter.format(to)}`
|
|
67
|
+
return `${fromStr} → ${toStr}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toLocalDateInputParts(sec: number): { date: Date; time: string } {
|
|
71
|
+
const d = new Date(sec * 1000)
|
|
72
|
+
const hh = String(d.getHours()).padStart(2, "0")
|
|
73
|
+
const mm = String(d.getMinutes()).padStart(2, "0")
|
|
74
|
+
return { date: d, time: `${hh}:${mm}` }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function combineDateAndTime(date: Date, time: string): number {
|
|
78
|
+
const [hh, mm] = time.split(":").map(n => parseInt(n, 10) || 0)
|
|
79
|
+
const d = new Date(date)
|
|
80
|
+
d.setHours(hh, mm, 0, 0)
|
|
81
|
+
return Math.floor(d.getTime() / 1000)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Flyout time-range picker with two tabs — Relative (a gallery of fixed windows, "20m"/"1h"/...)
|
|
86
|
+
* and Absolute (a date-range calendar plus start/end time-of-day) — for the cases where a
|
|
87
|
+
* relative-only picker (`RelativeTimeRangePicker`) isn't enough, e.g. inspecting a specific past
|
|
88
|
+
* incident window rather than "the last N minutes".
|
|
89
|
+
*/
|
|
90
|
+
export function TimeRangePicker({
|
|
91
|
+
id,
|
|
92
|
+
value,
|
|
93
|
+
onChange,
|
|
94
|
+
options,
|
|
95
|
+
disabled = false,
|
|
96
|
+
className,
|
|
97
|
+
align = "end",
|
|
98
|
+
triggerLabel = "Time range",
|
|
99
|
+
}: TimeRangePickerProps) {
|
|
100
|
+
const [open, setOpen] = React.useState(false)
|
|
101
|
+
const [tab, setTab] = React.useState<"relative" | "absolute">(value.mode)
|
|
102
|
+
|
|
103
|
+
// Draft absolute selection — only committed to `onChange` on "Apply", so picking a start date
|
|
104
|
+
// doesn't fire a query before the end date/time are set too.
|
|
105
|
+
const [draftRange, setDraftRange] = React.useState<DateRange | undefined>(() =>
|
|
106
|
+
value.mode === "absolute"
|
|
107
|
+
? { from: toLocalDateInputParts(value.from).date, to: toLocalDateInputParts(value.to).date }
|
|
108
|
+
: undefined
|
|
109
|
+
)
|
|
110
|
+
const [draftStartTime, setDraftStartTime] = React.useState(() =>
|
|
111
|
+
value.mode === "absolute" ? toLocalDateInputParts(value.from).time : "00:00"
|
|
112
|
+
)
|
|
113
|
+
const [draftEndTime, setDraftEndTime] = React.useState(() =>
|
|
114
|
+
value.mode === "absolute" ? toLocalDateInputParts(value.to).time : "23:59"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// Re-seed the draft from the live value whenever the popover opens, so reopening it doesn't
|
|
118
|
+
// show a stale in-progress edit from last time.
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
if (!open) return
|
|
121
|
+
setTab(value.mode)
|
|
122
|
+
if (value.mode === "absolute") {
|
|
123
|
+
const from = toLocalDateInputParts(value.from)
|
|
124
|
+
const to = toLocalDateInputParts(value.to)
|
|
125
|
+
setDraftRange({ from: from.date, to: to.date })
|
|
126
|
+
setDraftStartTime(from.time)
|
|
127
|
+
setDraftEndTime(to.time)
|
|
128
|
+
}
|
|
129
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
130
|
+
}, [open])
|
|
131
|
+
|
|
132
|
+
const relativeSelected = value.mode === "relative" ? options.find(o => o.seconds === value.seconds) : undefined
|
|
133
|
+
|
|
134
|
+
const [now, setNow] = React.useState(() => Date.now())
|
|
135
|
+
React.useEffect(() => { setNow(Date.now()) }, [value, open])
|
|
136
|
+
React.useEffect(() => {
|
|
137
|
+
const interval = setInterval(() => setNow(Date.now()), REFRESH_INTERVAL_MS)
|
|
138
|
+
return () => clearInterval(interval)
|
|
139
|
+
}, [])
|
|
140
|
+
|
|
141
|
+
const triggerText = value.mode === "relative"
|
|
142
|
+
? (relativeSelected ? formatSince(now - relativeSelected.seconds * 1000, relativeSelected.seconds >= DAY_SECONDS) : "Select…")
|
|
143
|
+
: formatAbsolute(value.from, value.to)
|
|
144
|
+
|
|
145
|
+
const applyAbsolute = () => {
|
|
146
|
+
if (!draftRange?.from) return
|
|
147
|
+
const from = combineDateAndTime(draftRange.from, draftStartTime)
|
|
148
|
+
const to = combineDateAndTime(draftRange.to ?? draftRange.from, draftEndTime)
|
|
149
|
+
onChange({ mode: "absolute", from, to })
|
|
150
|
+
setOpen(false)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
155
|
+
<PopoverTrigger asChild>
|
|
156
|
+
<Button
|
|
157
|
+
id={id}
|
|
158
|
+
type="button"
|
|
159
|
+
variant="secondary"
|
|
160
|
+
size="sm"
|
|
161
|
+
disabled={disabled}
|
|
162
|
+
aria-label={triggerLabel}
|
|
163
|
+
className={cn("rounded-none", className)}
|
|
164
|
+
leftIcon={<Clock className="h-3.5 w-3.5" />}
|
|
165
|
+
rightIcon={<ChevronDown className="h-3.5 w-3.5 opacity-60" />}
|
|
166
|
+
>
|
|
167
|
+
{triggerText}
|
|
168
|
+
</Button>
|
|
169
|
+
</PopoverTrigger>
|
|
170
|
+
<PopoverContent align={align} sideOffset={6} className="w-auto rounded-none p-0">
|
|
171
|
+
<div className="flex border-b border-ibm-gray-20">
|
|
172
|
+
{(["relative", "absolute"] as const).map(t => (
|
|
173
|
+
<button
|
|
174
|
+
key={t}
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => setTab(t)}
|
|
177
|
+
className={cn(
|
|
178
|
+
"flex-1 h-9 px-4 text-sm font-medium border-b-2 -mb-px transition-colors capitalize",
|
|
179
|
+
tab === t
|
|
180
|
+
? "border-ibm-blue-60 text-ibm-blue-60 bg-ibm-blue-10/60"
|
|
181
|
+
: "border-transparent text-ibm-gray-60 hover:text-ibm-blue-60 hover:bg-ibm-blue-10/40"
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
{t}
|
|
185
|
+
</button>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{tab === "relative" ? (
|
|
190
|
+
<div className="p-2">
|
|
191
|
+
<p className="px-1 pb-2 text-xs font-medium text-ibm-gray-50">{triggerLabel}</p>
|
|
192
|
+
<div role="listbox" aria-label={triggerLabel} className="grid grid-cols-3 gap-1.5">
|
|
193
|
+
{options.map(opt => {
|
|
194
|
+
const isSelected = value.mode === "relative" && opt.seconds === value.seconds
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
key={opt.seconds}
|
|
198
|
+
type="button"
|
|
199
|
+
role="option"
|
|
200
|
+
aria-selected={isSelected}
|
|
201
|
+
onClick={() => { onChange({ mode: "relative", seconds: opt.seconds }); setOpen(false) }}
|
|
202
|
+
className={cn(
|
|
203
|
+
"flex items-center justify-center gap-1 h-9 px-2 text-sm font-medium border transition-colors whitespace-nowrap",
|
|
204
|
+
isSelected
|
|
205
|
+
? "bg-ibm-blue-60 text-white border-ibm-blue-60"
|
|
206
|
+
: "bg-white text-ibm-gray-100 border-ibm-gray-20 hover:bg-ibm-blue-10 hover:border-ibm-blue-40"
|
|
207
|
+
)}
|
|
208
|
+
>
|
|
209
|
+
{isSelected && <Check className="h-3 w-3 flex-shrink-0" />}
|
|
210
|
+
{opt.label}
|
|
211
|
+
</button>
|
|
212
|
+
)
|
|
213
|
+
})}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
) : (
|
|
217
|
+
<div className="p-3 space-y-3">
|
|
218
|
+
<Calendar
|
|
219
|
+
mode="range"
|
|
220
|
+
selected={draftRange}
|
|
221
|
+
onSelect={setDraftRange}
|
|
222
|
+
numberOfMonths={1}
|
|
223
|
+
className="mx-auto"
|
|
224
|
+
/>
|
|
225
|
+
<div className="grid grid-cols-2 gap-3">
|
|
226
|
+
<div>
|
|
227
|
+
<label className="block text-xs font-medium text-ibm-gray-50 mb-1">Start time</label>
|
|
228
|
+
<Input type="time" value={draftStartTime} onChange={e => setDraftStartTime(e.target.value)} />
|
|
229
|
+
</div>
|
|
230
|
+
<div>
|
|
231
|
+
<label className="block text-xs font-medium text-ibm-gray-50 mb-1">End time</label>
|
|
232
|
+
<Input type="time" value={draftEndTime} onChange={e => setDraftEndTime(e.target.value)} />
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div className="flex justify-end pt-1">
|
|
236
|
+
<Button
|
|
237
|
+
type="button"
|
|
238
|
+
variant="primary"
|
|
239
|
+
className="rounded-none"
|
|
240
|
+
disabled={!draftRange?.from}
|
|
241
|
+
onClick={applyAbsolute}
|
|
242
|
+
>
|
|
243
|
+
Apply
|
|
244
|
+
</Button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</PopoverContent>
|
|
249
|
+
</Popover>
|
|
250
|
+
)
|
|
251
|
+
}
|