@orsetra/shared-ui 1.10.15 → 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.
@@ -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'
@@ -39,6 +39,15 @@ export interface ProjectSelectorModalProps extends ProjectSelectorProps {
39
39
  title?: string
40
40
  description?: string
41
41
  currentProjectId?: string
42
+ /**
43
+ * Escape hatch shown next to "Create & Continue" only in the zero-business-unit state (this
44
+ * dialog otherwise can't be dismissed while no project is selected — see `onInteractOutside`
45
+ * below — so without this a user who can't or doesn't want to create one here has no way out).
46
+ * Omit to keep the dialog exactly as strict as before.
47
+ */
48
+ escapeHref?: string
49
+ /** Label for `escapeHref`. Defaults to "Go to main app". */
50
+ escapeLabel?: string
42
51
  }
43
52
 
44
53
  export function ProjectSelectorModal({
@@ -50,6 +59,8 @@ export function ProjectSelectorModal({
50
59
  getProjects,
51
60
  createProject,
52
61
  doInit,
62
+ escapeHref,
63
+ escapeLabel = "Go to main app",
53
64
  }: ProjectSelectorModalProps) {
54
65
  const [projects, setProjects] = React.useState<Project[]>([])
55
66
  const [loading, setLoading] = React.useState(true)
@@ -213,6 +224,14 @@ export function ProjectSelectorModal({
213
224
  )}
214
225
 
215
226
  <div className="flex items-center justify-end gap-3 pt-2">
227
+ {!loading && projects.length === 0 && escapeHref && (
228
+ <a
229
+ href={escapeHref}
230
+ className="inline-flex items-center justify-center rounded-none border border-ibm-gray-30 text-ibm-gray-100 hover:bg-ibm-gray-30 active:bg-ibm-gray-40 font-semibold h-10 px-4 text-base transition-colors mr-auto"
231
+ >
232
+ {escapeLabel}
233
+ </a>
234
+ )}
216
235
  <Button
217
236
  type="submit"
218
237
  className="rounded-none"
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.10.15",
3
+ "version": "1.10.17",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",