@skyhook-io/radar-app 1.0.1 → 1.0.2

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.
@@ -0,0 +1,446 @@
1
+ import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { clsx } from 'clsx'
4
+ import { ChevronDown, Search, X, Check, Shield } from 'lucide-react'
5
+ import { Tooltip } from './Tooltip'
6
+ import { isForbiddenError } from '../../api/client'
7
+
8
+ interface Namespace {
9
+ name: string
10
+ }
11
+
12
+ export interface NamespaceSelectorHandle {
13
+ open: () => void
14
+ }
15
+
16
+ interface NamespaceSelectorProps {
17
+ value: string[]
18
+ onChange: (value: string[]) => void
19
+ namespaces: Namespace[] | undefined
20
+ namespacesError?: Error | null
21
+ className?: string
22
+ disabled?: boolean
23
+ disabledTooltip?: string
24
+ }
25
+
26
+ export const NamespaceSelector = forwardRef<NamespaceSelectorHandle, NamespaceSelectorProps>(({
27
+ value,
28
+ onChange,
29
+ namespaces,
30
+ namespacesError,
31
+ className,
32
+ disabled,
33
+ disabledTooltip,
34
+ }, ref) => {
35
+ const [isOpen, setIsOpen] = useState(false)
36
+ const [search, setSearch] = useState('')
37
+ const [manualInput, setManualInput] = useState('')
38
+ const [highlightedIndex, setHighlightedIndex] = useState(0)
39
+ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 })
40
+
41
+ const triggerRef = useRef<HTMLButtonElement>(null)
42
+ const dropdownRef = useRef<HTMLDivElement>(null)
43
+ const searchInputRef = useRef<HTMLInputElement>(null)
44
+
45
+ const isForbidden = isForbiddenError(namespacesError)
46
+
47
+ // Convert value to Set for efficient lookups
48
+ const selectedSet = useMemo(() => new Set(value), [value])
49
+
50
+ // Sort and filter namespaces
51
+ const sortedNamespaces = useMemo(() => {
52
+ if (!namespaces) return []
53
+ return [...namespaces].sort((a, b) => a.name.localeCompare(b.name))
54
+ }, [namespaces])
55
+
56
+ const filteredNamespaces = useMemo(() => {
57
+ if (!search.trim()) return sortedNamespaces
58
+ const searchLower = search.toLowerCase()
59
+ return sortedNamespaces.filter((ns) =>
60
+ ns.name.toLowerCase().includes(searchLower)
61
+ )
62
+ }, [sortedNamespaces, search])
63
+
64
+ // Update position when dropdown opens
65
+ const updatePosition = useCallback(() => {
66
+ if (!triggerRef.current) return
67
+ const rect = triggerRef.current.getBoundingClientRect()
68
+ const dropdownWidth = Math.max(rect.width, 220) // Minimum width for checkboxes
69
+ // Align dropdown to the right edge of the button
70
+ const left = rect.right - dropdownWidth
71
+ setDropdownPosition({
72
+ top: rect.bottom + 4,
73
+ left: Math.max(8, left), // Ensure at least 8px from screen edge
74
+ width: dropdownWidth,
75
+ })
76
+ }, [])
77
+
78
+ // Open dropdown
79
+ const openDropdown = useCallback(() => {
80
+ if (disabled) return
81
+ setIsOpen(true)
82
+ setSearch('')
83
+ setHighlightedIndex(0)
84
+ updatePosition()
85
+ }, [disabled, updatePosition])
86
+
87
+ // Expose open method via ref
88
+ useImperativeHandle(ref, () => ({
89
+ open: openDropdown
90
+ }), [openDropdown])
91
+
92
+ // Close dropdown
93
+ const closeDropdown = useCallback(() => {
94
+ setIsOpen(false)
95
+ setSearch('')
96
+ }, [])
97
+
98
+ // Toggle a namespace selection
99
+ const toggleNamespace = useCallback((ns: string) => {
100
+ if (selectedSet.has(ns)) {
101
+ onChange(value.filter((v) => v !== ns))
102
+ } else {
103
+ onChange([...value, ns])
104
+ }
105
+ }, [selectedSet, value, onChange])
106
+
107
+ // Select all visible namespaces
108
+ const selectAll = useCallback(() => {
109
+ const allNames = sortedNamespaces.map((ns) => ns.name)
110
+ onChange(allNames)
111
+ }, [sortedNamespaces, onChange])
112
+
113
+ // Clear all selections (shows all namespaces)
114
+ const clearAll = useCallback(() => {
115
+ onChange([])
116
+ }, [onChange])
117
+
118
+ // Add a manually typed namespace
119
+ const addManualNamespace = useCallback(() => {
120
+ const ns = manualInput.trim()
121
+ if (ns && !selectedSet.has(ns)) {
122
+ onChange([...value, ns])
123
+ }
124
+ setManualInput('')
125
+ }, [manualInput, selectedSet, value, onChange])
126
+
127
+ // Focus search input when dropdown opens
128
+ useEffect(() => {
129
+ if (isOpen) {
130
+ // Small delay to ensure the dropdown is rendered
131
+ requestAnimationFrame(() => {
132
+ searchInputRef.current?.focus()
133
+ })
134
+ }
135
+ }, [isOpen])
136
+
137
+ // Reset highlighted index when filtered options change
138
+ useEffect(() => {
139
+ setHighlightedIndex(0)
140
+ }, [filteredNamespaces])
141
+
142
+ // Handle click outside
143
+ useEffect(() => {
144
+ if (!isOpen) return
145
+
146
+ const handleClickOutside = (e: MouseEvent) => {
147
+ const target = e.target as Node
148
+ if (
149
+ triggerRef.current?.contains(target) ||
150
+ dropdownRef.current?.contains(target)
151
+ ) {
152
+ return
153
+ }
154
+ closeDropdown()
155
+ }
156
+
157
+ // Small delay to prevent immediate close on open click
158
+ const timeoutId = setTimeout(() => {
159
+ document.addEventListener('mousedown', handleClickOutside)
160
+ }, 0)
161
+
162
+ return () => {
163
+ clearTimeout(timeoutId)
164
+ document.removeEventListener('mousedown', handleClickOutside)
165
+ }
166
+ }, [isOpen, closeDropdown])
167
+
168
+ // Handle keyboard navigation
169
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
170
+ switch (e.key) {
171
+ case 'ArrowDown':
172
+ e.preventDefault()
173
+ setHighlightedIndex((prev) =>
174
+ prev < filteredNamespaces.length - 1 ? prev + 1 : prev
175
+ )
176
+ break
177
+ case 'ArrowUp':
178
+ e.preventDefault()
179
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0))
180
+ break
181
+ case 'Enter':
182
+ case ' ':
183
+ e.preventDefault()
184
+ if (filteredNamespaces[highlightedIndex]) {
185
+ toggleNamespace(filteredNamespaces[highlightedIndex].name)
186
+ }
187
+ break
188
+ case 'Escape':
189
+ e.preventDefault()
190
+ closeDropdown()
191
+ break
192
+ case 'Tab':
193
+ closeDropdown()
194
+ break
195
+ }
196
+ }, [filteredNamespaces, highlightedIndex, toggleNamespace, closeDropdown])
197
+
198
+ // Scroll highlighted item into view
199
+ useEffect(() => {
200
+ if (!isOpen || !dropdownRef.current) return
201
+ const highlighted = dropdownRef.current.querySelector('[data-highlighted="true"]')
202
+ if (highlighted) {
203
+ highlighted.scrollIntoView({ block: 'nearest' })
204
+ }
205
+ }, [highlightedIndex, isOpen])
206
+
207
+ // Get display value
208
+ const displayValue = useMemo(() => {
209
+ if (value.length === 0) return 'All Namespaces'
210
+ if (value.length === 1) return value[0]
211
+ return `${value.length} namespaces`
212
+ }, [value])
213
+
214
+ const allSelected = sortedNamespaces.length > 0 && value.length === sortedNamespaces.length
215
+
216
+ return (
217
+ <>
218
+ <Tooltip content={disabledTooltip} disabled={!disabled} position="bottom">
219
+ <button
220
+ ref={triggerRef}
221
+ type="button"
222
+ disabled={disabled}
223
+ onClick={() => (isOpen ? closeDropdown() : openDropdown())}
224
+ className={clsx(
225
+ 'appearance-none bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1 pr-6 border border-theme-border-light',
226
+ 'focus:outline-none focus:ring-1 focus:ring-blue-500 min-w-[100px] text-left relative',
227
+ 'transition-colors',
228
+ disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-theme-hover',
229
+ className
230
+ )}
231
+ >
232
+ <span className="block truncate">{displayValue}</span>
233
+ <ChevronDown
234
+ className={clsx(
235
+ 'absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-secondary transition-transform',
236
+ isOpen && 'rotate-180'
237
+ )}
238
+ />
239
+ </button>
240
+ </Tooltip>
241
+
242
+ {isOpen &&
243
+ createPortal(
244
+ <>
245
+ {/* Backdrop to capture clicks outside the dropdown */}
246
+ <div
247
+ className="fixed inset-0 z-[9998]"
248
+ onClick={closeDropdown}
249
+ />
250
+ <div
251
+ ref={dropdownRef}
252
+ className="fixed z-[9999] bg-theme-elevated border border-theme-border rounded-md shadow-lg overflow-hidden"
253
+ style={{
254
+ top: dropdownPosition.top,
255
+ left: dropdownPosition.left,
256
+ width: dropdownPosition.width,
257
+ }}
258
+ onKeyDown={handleKeyDown}
259
+ >
260
+ {isForbidden ? (
261
+ <>
262
+ {/* Manual namespace input when listing is forbidden */}
263
+ <div className="p-2 border-b border-theme-border">
264
+ <div className="flex items-center gap-1.5 text-[10px] text-amber-400 mb-2">
265
+ <Shield className="w-3 h-3" />
266
+ <span>Cannot list namespaces — type a name</span>
267
+ </div>
268
+ <div className="flex gap-1">
269
+ <input
270
+ ref={searchInputRef}
271
+ type="text"
272
+ value={manualInput}
273
+ onChange={(e) => setManualInput(e.target.value)}
274
+ onKeyDown={(e) => {
275
+ if (e.key === 'Enter') {
276
+ e.preventDefault()
277
+ addManualNamespace()
278
+ } else if (e.key === 'Escape') {
279
+ closeDropdown()
280
+ }
281
+ }}
282
+ placeholder="Namespace name..."
283
+ className="flex-1 bg-theme-base text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-theme-text-tertiary"
284
+ />
285
+ <button
286
+ type="button"
287
+ onClick={addManualNamespace}
288
+ disabled={!manualInput.trim()}
289
+ className="px-2 py-1 text-xs btn-brand rounded"
290
+ >
291
+ Add
292
+ </button>
293
+ </div>
294
+ </div>
295
+
296
+ {/* Show currently selected namespaces with remove button */}
297
+ <div className="max-h-[200px] overflow-y-auto">
298
+ {value.length === 0 ? (
299
+ <div className="px-3 py-4 text-center text-xs text-theme-text-tertiary">
300
+ All namespaces (type a name to filter)
301
+ </div>
302
+ ) : (
303
+ value.map((ns) => (
304
+ <div
305
+ key={ns}
306
+ className="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between gap-2 text-theme-text-primary hover:bg-theme-hover"
307
+ >
308
+ <span className="truncate">{ns}</span>
309
+ <button
310
+ type="button"
311
+ onClick={() => onChange(value.filter(v => v !== ns))}
312
+ className="text-theme-text-tertiary hover:text-red-400 flex-shrink-0"
313
+ >
314
+ <X className="w-3 h-3" />
315
+ </button>
316
+ </div>
317
+ ))
318
+ )}
319
+ </div>
320
+
321
+ {value.length > 0 && (
322
+ <div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base flex justify-between">
323
+ <span>{value.length} selected</span>
324
+ <button type="button" onClick={clearAll} className="text-blue-400 hover:text-blue-300">
325
+ Clear all
326
+ </button>
327
+ </div>
328
+ )}
329
+ </>
330
+ ) : (
331
+ <>
332
+ {/* Search input */}
333
+ <div className="p-2 border-b border-theme-border">
334
+ <div className="relative">
335
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
336
+ <input
337
+ ref={searchInputRef}
338
+ type="text"
339
+ value={search}
340
+ onChange={(e) => setSearch(e.target.value)}
341
+ placeholder="Search namespaces..."
342
+ className="w-full bg-theme-base text-theme-text-primary text-xs rounded px-2 py-1.5 pl-7 pr-7 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-theme-text-tertiary"
343
+ />
344
+ {search && (
345
+ <button
346
+ type="button"
347
+ onClick={() => setSearch('')}
348
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-secondary"
349
+ >
350
+ <X className="w-3.5 h-3.5" />
351
+ </button>
352
+ )}
353
+ </div>
354
+ </div>
355
+
356
+ {/* Select All / Clear All buttons */}
357
+ <div className="flex gap-1 px-2 py-1.5 border-b border-theme-border bg-theme-base">
358
+ <button
359
+ type="button"
360
+ onClick={selectAll}
361
+ disabled={allSelected}
362
+ className={clsx(
363
+ 'flex-1 text-[10px] px-2 py-1 rounded transition-colors',
364
+ allSelected
365
+ ? 'text-theme-text-tertiary cursor-not-allowed'
366
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
367
+ )}
368
+ >
369
+ Select All
370
+ </button>
371
+ <button
372
+ type="button"
373
+ onClick={clearAll}
374
+ disabled={value.length === 0}
375
+ className={clsx(
376
+ 'flex-1 text-[10px] px-2 py-1 rounded transition-colors',
377
+ value.length === 0
378
+ ? 'text-theme-text-tertiary cursor-not-allowed'
379
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
380
+ )}
381
+ >
382
+ Clear All
383
+ </button>
384
+ </div>
385
+
386
+ {/* Options list with checkboxes */}
387
+ <div className="max-h-[240px] overflow-y-auto">
388
+ {filteredNamespaces.length === 0 ? (
389
+ <div className="px-3 py-6 text-center text-xs text-theme-text-tertiary">
390
+ No namespaces match "{search}"
391
+ </div>
392
+ ) : (
393
+ filteredNamespaces.map((ns, index) => {
394
+ const isSelected = selectedSet.has(ns.name)
395
+ return (
396
+ <button
397
+ key={ns.name}
398
+ type="button"
399
+ data-highlighted={index === highlightedIndex}
400
+ onClick={() => toggleNamespace(ns.name)}
401
+ onMouseEnter={() => setHighlightedIndex(index)}
402
+ className={clsx(
403
+ 'w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2',
404
+ 'text-theme-text-primary',
405
+ index === highlightedIndex && 'bg-theme-hover'
406
+ )}
407
+ >
408
+ <div
409
+ className={clsx(
410
+ 'w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0',
411
+ isSelected
412
+ ? 'bg-blue-500 border-blue-500'
413
+ : 'border-theme-border-light bg-theme-base'
414
+ )}
415
+ >
416
+ {isSelected && <Check className="w-2.5 h-2.5 text-white" />}
417
+ </div>
418
+ <span className="truncate">{ns.name}</span>
419
+ </button>
420
+ )
421
+ })
422
+ )}
423
+ </div>
424
+
425
+ {/* Namespace count and selection info */}
426
+ {sortedNamespaces.length > 0 && (
427
+ <div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base flex justify-between">
428
+ <span>
429
+ {filteredNamespaces.length === sortedNamespaces.length
430
+ ? `${sortedNamespaces.length} namespaces`
431
+ : `${filteredNamespaces.length} of ${sortedNamespaces.length} namespaces`}
432
+ </span>
433
+ {value.length > 0 && (
434
+ <span className="text-blue-400">{value.length} selected</span>
435
+ )}
436
+ </div>
437
+ )}
438
+ </>
439
+ )}
440
+ </div>
441
+ </>,
442
+ document.body
443
+ )}
444
+ </>
445
+ )
446
+ })