@skyhook-io/k8s-ui 1.4.1 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useRef, forwardRef } from 'react'
1
+ import { useState, useMemo, useEffect, useRef, useCallback, forwardRef } from 'react'
2
2
  import {
3
3
  Search,
4
4
  ChevronDown,
@@ -44,6 +44,9 @@ export interface ResourcesSidebarProps {
44
44
  onNavigate?: (path: string) => void
45
45
  /** Base path for generating navigation URLs (e.g., '/org/clusters/id/k8s-resources') */
46
46
  basePath?: string
47
+ /** Called when a kind is selected via keyboard (Enter in the filter). Parent uses this
48
+ * to move focus to the next UI level (e.g., the table search input). */
49
+ onKindNavigated?: () => void
47
50
  }
48
51
 
49
52
  // Persisted across remounts so collapsed categories survive tab switches
@@ -93,6 +96,8 @@ interface ResourceTypeButtonProps {
93
96
  resource: APIResource
94
97
  count: number
95
98
  isSelected: boolean
99
+ /** Keyboard-highlight state (arrow nav in the filter input). */
100
+ isHighlighted?: boolean
96
101
  isForbidden?: boolean
97
102
  isPinned?: boolean
98
103
  onTogglePin?: () => void
@@ -100,7 +105,7 @@ interface ResourceTypeButtonProps {
100
105
  }
101
106
 
102
107
  const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
103
- function ResourceTypeButton({ resource, count, isSelected, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
108
+ function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
104
109
  const Icon = getResourceIcon(resource.kind)
105
110
  return (
106
111
  <button
@@ -110,9 +115,11 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
110
115
  'w-full flex items-center gap-2 px-2 xl:px-3 py-1.5 rounded-lg text-sm transition-colors group/kind min-w-0',
111
116
  isSelected
112
117
  ? 'selection-strong selection-text'
113
- : forbidden
114
- ? 'text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary'
115
- : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
118
+ : isHighlighted
119
+ ? 'bg-theme-hover text-theme-text-primary'
120
+ : forbidden
121
+ ? 'text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary'
122
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
116
123
  )}
117
124
  >
118
125
  <Icon className="w-4 h-4 shrink-0" />
@@ -172,6 +179,7 @@ export function ResourcesSidebar({
172
179
  className,
173
180
  onNavigate,
174
181
  basePath,
182
+ onKindNavigated,
175
183
  }: ResourcesSidebarProps) {
176
184
  // Wraps kind selection to also navigate when basePath/onNavigate are provided
177
185
  const selectKind = (kind: SelectedKindInfo) => {
@@ -195,6 +203,15 @@ export function ResourcesSidebar({
195
203
  // Ref to selected sidebar item for scrolling into view on deeplink
196
204
  const selectedSidebarRef = useRef<HTMLButtonElement>(null)
197
205
 
206
+ // Ref to kind search input — auto-focused on mount so users can type a kind name immediately
207
+ const kindSearchRef = useRef<HTMLInputElement>(null)
208
+ useEffect(() => {
209
+ // Only focus on fine-pointer devices to avoid popping up the virtual keyboard on touch
210
+ if (typeof window !== 'undefined' && window.matchMedia('(pointer: fine)').matches) {
211
+ kindSearchRef.current?.focus()
212
+ }
213
+ }, [])
214
+
198
215
  // Effective selected kind — fall back to a safe default
199
216
  const effectiveSelectedKind = selectedKind ?? { name: 'pods', kind: 'Pod', group: '' }
200
217
 
@@ -335,6 +352,69 @@ export function ResourcesSidebar({
335
352
  })
336
353
  }
337
354
 
355
+ // --- Keyboard navigation ---
356
+ // Flat list of all navigable kinds in the order they appear in the sidebar.
357
+ const flatVisibleKinds = useMemo<SelectedKindInfo[]>(() => {
358
+ const kinds: SelectedKindInfo[] = []
359
+ if (favoritesExpanded) {
360
+ for (const p of pinned) {
361
+ kinds.push({ name: p.name, kind: p.kind, group: p.group })
362
+ }
363
+ }
364
+ if (filteredCategories) {
365
+ for (const cat of filteredCategories) {
366
+ if (effectiveExpandedCategories.has(cat.name)) {
367
+ for (const r of cat.visibleResources) {
368
+ kinds.push({ name: r.name, kind: r.kind, group: r.group })
369
+ }
370
+ }
371
+ }
372
+ }
373
+ return kinds
374
+ }, [favoritesExpanded, pinned, filteredCategories, effectiveExpandedCategories])
375
+
376
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
377
+ // Reset highlight when the filter or kind list changes
378
+ useEffect(() => {
379
+ setHighlightedIndex(kindFilter ? 0 : -1)
380
+ }, [kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
381
+
382
+ const highlightedKind = highlightedIndex >= 0 && highlightedIndex < flatVisibleKinds.length
383
+ ? flatVisibleKinds[highlightedIndex]
384
+ : null
385
+
386
+ // Scroll the highlighted kind button into view
387
+ const highlightedRef = useRef<HTMLButtonElement>(null)
388
+ useEffect(() => {
389
+ if (highlightedRef.current) {
390
+ highlightedRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
391
+ }
392
+ }, [highlightedIndex])
393
+
394
+ const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
395
+ if (e.key === 'Escape') {
396
+ setKindFilter('')
397
+ setHighlightedIndex(-1)
398
+ ;(e.target as HTMLInputElement).blur()
399
+ } else if (e.key === 'ArrowDown') {
400
+ e.preventDefault()
401
+ setHighlightedIndex(prev => Math.min(prev + 1, flatVisibleKinds.length - 1))
402
+ } else if (e.key === 'ArrowUp') {
403
+ e.preventDefault()
404
+ setHighlightedIndex(prev => Math.max(prev - 1, 0))
405
+ } else if (e.key === 'Enter' && highlightedKind) {
406
+ e.preventDefault()
407
+ selectKind(highlightedKind)
408
+ setHighlightedIndex(-1)
409
+ setKindFilter('')
410
+ onKindNavigated?.()
411
+ }
412
+ }, [flatVisibleKinds.length, highlightedKind, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
413
+
414
+ const isKindHighlighted = useCallback((name: string, group: string) => {
415
+ return highlightedKind?.name === name && highlightedKind?.group === group
416
+ }, [highlightedKind])
417
+
338
418
  // Scroll sidebar to show selected kind on mount (deep linking) and on kind changes (keyboard nav)
339
419
  const lastScrolledKind = useRef<string | null>(null)
340
420
  const isInitialScroll = useRef(true)
@@ -362,11 +442,12 @@ export function ResourcesSidebar({
362
442
  <div className="relative">
363
443
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
364
444
  <input
445
+ ref={kindSearchRef}
365
446
  type="text"
366
447
  placeholder="Filter resources..."
367
448
  value={kindFilter}
368
449
  onChange={(e) => setKindFilter(e.target.value)}
369
- onKeyDown={(e) => { if (e.key === 'Escape') { setKindFilter(''); (e.target as HTMLInputElement).blur() } }}
450
+ onKeyDown={handleSearchKeyDown}
370
451
  className="w-full pl-7 pr-7 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
371
452
  />
372
453
  {kindFilter && (
@@ -398,34 +479,41 @@ export function ResourcesSidebar({
398
479
  </span>
399
480
  )}
400
481
  </button>
401
- {favoritesExpanded && (
402
- <div className="space-y-0.5">
403
- {pinned.length === 0 ? (
404
- <div className="px-3 py-2 text-xs text-theme-text-disabled">
405
- No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
406
- </div>
407
- ) : (
408
- pinned.map((p) => {
409
- const isResourceSelected =
410
- (effectiveSelectedKind.name === p.name && effectiveSelectedKind.group === p.group) ||
411
- (effectiveSelectedKind.kind.toLowerCase() === p.kind.toLowerCase() && effectiveSelectedKind.group === p.group)
412
- return (
413
- <ResourceTypeButton
414
- key={`${p.name}-${p.group}`}
415
- ref={isResourceSelected ? selectedSidebarRef : null}
416
- resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
417
- count={counts?.[(p.group ? `${p.group}/${p.kind}` : p.kind)] ?? 0}
418
- isSelected={isResourceSelected}
419
- isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
420
- isPinned={true}
421
- onTogglePin={() => togglePin(p)}
422
- onClick={() => selectKind({ name: p.name, kind: p.kind, group: p.group })}
423
- />
424
- )
425
- })
426
- )}
482
+ <div className={clsx(
483
+ 'grid transition-[grid-template-rows] duration-200',
484
+ favoritesExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
485
+ )} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}>
486
+ <div className="overflow-hidden">
487
+ <div className="space-y-0.5">
488
+ {pinned.length === 0 ? (
489
+ <div className="px-3 py-2 text-xs text-theme-text-disabled">
490
+ No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
491
+ </div>
492
+ ) : (
493
+ pinned.map((p) => {
494
+ const isResourceSelected =
495
+ (effectiveSelectedKind.name === p.name && effectiveSelectedKind.group === p.group) ||
496
+ (effectiveSelectedKind.kind.toLowerCase() === p.kind.toLowerCase() && effectiveSelectedKind.group === p.group)
497
+ const highlighted = isKindHighlighted(p.name, p.group)
498
+ return (
499
+ <ResourceTypeButton
500
+ key={`${p.name}-${p.group}`}
501
+ ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
502
+ resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
503
+ count={counts?.[(p.group ? `${p.group}/${p.kind}` : p.kind)] ?? 0}
504
+ isSelected={isResourceSelected}
505
+ isHighlighted={highlighted}
506
+ isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
507
+ isPinned={true}
508
+ onTogglePin={() => togglePin(p)}
509
+ onClick={() => selectKind({ name: p.name, kind: p.kind, group: p.group })}
510
+ />
511
+ )
512
+ })
513
+ )}
514
+ </div>
427
515
  </div>
428
- )}
516
+ </div>
429
517
  </div>
430
518
  {filteredCategories ? (
431
519
  // Dynamic categories from API
@@ -449,31 +537,38 @@ export function ResourcesSidebar({
449
537
  </span>
450
538
  )}
451
539
  </button>
452
- {isExpanded && (
453
- <div className="space-y-0.5">
454
- {category.visibleResources.map((resource) => {
455
- const resourceIsPinned = isPinned(resource.name, resource.group)
456
- const isResourceSelected =
457
- (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
458
- (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
459
- // If the resource is pinned, let the Favorites section own the highlight
460
- const showSelected = isResourceSelected && !resourceIsPinned
461
- return (
462
- <ResourceTypeButton
463
- key={resource.name}
464
- ref={isResourceSelected ? selectedSidebarRef : null}
465
- resource={resource}
466
- count={counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0}
467
- isSelected={showSelected}
468
- isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
469
- isPinned={resourceIsPinned}
470
- onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
471
- onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
472
- />
473
- )
474
- })}
540
+ <div className={clsx(
541
+ 'grid transition-[grid-template-rows] duration-200',
542
+ isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
543
+ )} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}>
544
+ <div className="overflow-hidden">
545
+ <div className="space-y-0.5">
546
+ {category.visibleResources.map((resource) => {
547
+ const resourceIsPinned = isPinned(resource.name, resource.group)
548
+ const isResourceSelected =
549
+ (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
550
+ (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
551
+ // If the resource is pinned, let the Favorites section own the highlight
552
+ const showSelected = isResourceSelected && !resourceIsPinned
553
+ const highlighted = isKindHighlighted(resource.name, resource.group)
554
+ return (
555
+ <ResourceTypeButton
556
+ key={resource.name}
557
+ ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
558
+ resource={resource}
559
+ count={counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0}
560
+ isSelected={showSelected}
561
+ isHighlighted={highlighted}
562
+ isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
563
+ isPinned={resourceIsPinned}
564
+ onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
565
+ onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
566
+ />
567
+ )
568
+ })}
569
+ </div>
475
570
  </div>
476
- )}
571
+ </div>
477
572
  </div>
478
573
  )
479
574
  })
@@ -2938,6 +2938,11 @@ export function ResourcesView({
2938
2938
  pinned={pinned}
2939
2939
  togglePin={togglePin}
2940
2940
  isPinned={isPinned}
2941
+ onKindNavigated={() => {
2942
+ // After selecting a kind via keyboard, move focus to the table search
2943
+ // so the user can immediately filter within the selected kind.
2944
+ setTimeout(() => searchInputRef.current?.focus(), 50)
2945
+ }}
2941
2946
  />
2942
2947
  )}
2943
2948
 
@@ -2953,6 +2958,30 @@ export function ResourcesView({
2953
2958
  placeholder="Search... (press /)"
2954
2959
  value={searchTerm}
2955
2960
  onChange={(e) => setSearchTerm(e.target.value)}
2961
+ onKeyDown={(e) => {
2962
+ if (e.key === 'ArrowDown') {
2963
+ // Hand off to the table's keyboard navigation — blur the input
2964
+ // so the registered ArrowDown/j/k shortcuts take over, and
2965
+ // highlight the first row.
2966
+ e.preventDefault()
2967
+ searchInputRef.current?.blur()
2968
+ setHighlightedIndex(0)
2969
+ } else if (e.key === 'Enter' && filteredResourceCountRef.current > 0) {
2970
+ // Select the first (or currently highlighted) resource
2971
+ e.preventDefault()
2972
+ searchInputRef.current?.blur()
2973
+ if (highlightedIndex < 0) setHighlightedIndex(0)
2974
+ // Defer to next frame so the highlight renders before we open
2975
+ requestAnimationFrame(() => {
2976
+ const res = highlightedResourceRef.current ?? filteredResources[0]
2977
+ if (res?.metadata?.name) {
2978
+ onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
2979
+ }
2980
+ })
2981
+ } else if (e.key === 'Escape') {
2982
+ searchInputRef.current?.blur()
2983
+ }
2984
+ }}
2956
2985
  className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
2957
2986
  />
2958
2987
  </div>
@@ -1,4 +1,4 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useEffect, useRef } from 'react'
2
2
  import {
3
3
  Copy,
4
4
  CopyPlus,
@@ -91,6 +91,18 @@ function formatSaveError(error: string): { summary: string; details?: string } {
91
91
  return { summary: error }
92
92
  }
93
93
 
94
+ // Safe sessionStorage wrappers — storage can throw QuotaExceededError or be
95
+ // blocked by browser security policies. Draft persistence is best-effort.
96
+ function safeSessionGet(key: string): string | null {
97
+ try { return sessionStorage.getItem(key) } catch { return null }
98
+ }
99
+ function safeSessionSet(key: string, value: string): void {
100
+ try { sessionStorage.setItem(key, value) } catch { /* best-effort */ }
101
+ }
102
+ function safeSessionRemove(key: string): void {
103
+ try { sessionStorage.removeItem(key) } catch { /* best-effort */ }
104
+ }
105
+
94
106
  interface EditableYamlViewProps {
95
107
  resource: SelectedResource
96
108
  data: any
@@ -109,11 +121,34 @@ interface EditableYamlViewProps {
109
121
  }
110
122
 
111
123
  export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate }: EditableYamlViewProps) {
112
- const [isEditing, setIsEditing] = useState(false)
113
- const [editedYaml, setEditedYaml] = useState('')
124
+ const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
125
+
126
+ // Restore draft from sessionStorage (e.g., after session-expiry redirect).
127
+ // All sessionStorage calls are wrapped in try-catch — storage can throw
128
+ // QuotaExceededError or be blocked by browser security policies.
129
+ const savedDraft = useRef(safeSessionGet(draftKey))
130
+ const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
131
+ const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
114
132
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
115
133
  const [showErrorDetails, setShowErrorDetails] = useState(false)
116
134
 
135
+ // Clean up restored draft flag
136
+ useEffect(() => {
137
+ if (typeof savedDraft.current === 'string') {
138
+ safeSessionRemove(draftKey)
139
+ savedDraft.current = null
140
+ }
141
+ }, [draftKey])
142
+
143
+ // Autosave draft to sessionStorage while editing (best-effort)
144
+ useEffect(() => {
145
+ if (isEditing && editedYaml) {
146
+ safeSessionSet(draftKey, editedYaml)
147
+ } else {
148
+ safeSessionRemove(draftKey)
149
+ }
150
+ }, [isEditing, editedYaml, draftKey])
151
+
117
152
  // Convert resource to YAML for editing
118
153
  const convertToYaml = useCallback((d: any) => {
119
154
  if (!d) return ''
@@ -109,6 +109,22 @@ export function Tooltip({
109
109
  }
110
110
  }, [])
111
111
 
112
+ // When disabled flips true, proactively cancel any pending show timer and
113
+ // clear visible state. Without this, a tooltip that was visible (or armed)
114
+ // when disabled became true would pop back on as soon as disabled flips
115
+ // false — even though the cursor is elsewhere and no fresh mouseenter has
116
+ // fired. Also covers the case where the trigger becomes unreachable via
117
+ // pointer-events-none and never fires mouseleave.
118
+ useEffect(() => {
119
+ if (disabled) {
120
+ if (timeoutRef.current) {
121
+ clearTimeout(timeoutRef.current)
122
+ timeoutRef.current = null
123
+ }
124
+ setIsVisible(false)
125
+ }
126
+ }, [disabled])
127
+
112
128
  if (disabled || !content) {
113
129
  return <>{children}</>
114
130
  }