@skyhook-io/k8s-ui 1.14.6 → 1.14.8

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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationDetail.tsx +36 -4
  3. package/src/components/applications/ApplicationsView.tsx +2 -1
  4. package/src/components/audit/AuditAlerts.tsx +35 -37
  5. package/src/components/audit/AuditFindingsTable.tsx +23 -27
  6. package/src/components/charts/PrometheusChartsView.tsx +29 -10
  7. package/src/components/checks/ChecksView.tsx +56 -26
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +12 -9
  9. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +92 -0
  10. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +36 -6
  11. package/src/components/issues/IssuesView.tsx +35 -54
  12. package/src/components/logs/LogCore.tsx +40 -9
  13. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  14. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  15. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  16. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  17. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  18. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  19. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  20. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  21. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  22. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  23. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +13 -6
  25. package/src/components/shared/CreateResourceDialog.tsx +7 -4
  26. package/src/components/timeline/TimelineList.tsx +14 -11
  27. package/src/components/timeline/TimelineStrip.tsx +26 -4
  28. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  29. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  30. package/src/components/topology/K8sResourceNode.tsx +4 -2
  31. package/src/components/topology/TopologyControls.tsx +27 -8
  32. package/src/components/topology/TopologyGraph.tsx +14 -5
  33. package/src/components/trace/ReachabilityView.tsx +17 -16
  34. package/src/components/ui/CardSection.tsx +79 -35
  35. package/src/components/ui/Collapse.test.tsx +61 -0
  36. package/src/components/ui/Collapse.tsx +115 -29
  37. package/src/components/ui/DialogPortal.tsx +7 -2
  38. package/src/components/ui/Disclosure.test.tsx +26 -0
  39. package/src/components/ui/Disclosure.tsx +69 -0
  40. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  41. package/src/components/ui/RestrictedState.tsx +7 -4
  42. package/src/components/ui/RowActionMenu.tsx +20 -8
  43. package/src/components/ui/SelectMenu.tsx +18 -6
  44. package/src/components/ui/YamlEditor.tsx +7 -9
  45. package/src/components/ui/drawer-components.tsx +14 -21
  46. package/src/components/ui/index.ts +31 -22
  47. package/src/components/workload/WorkloadView.tsx +6 -10
  48. package/src/hooks/useAnimatedUnmount.ts +21 -3
  49. package/src/theme/components.css +0 -19
  50. package/src/utils/animation.ts +93 -13
@@ -1,7 +1,8 @@
1
- import { useState } from 'react'
2
- import { ChevronDown, ChevronRight, Box } from 'lucide-react'
1
+ import { useState, useId } from 'react'
2
+ import { Box } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { formatAge } from '../../resource-utils'
5
+ import { Collapse, CollapseChevron, disclosurePanelId } from '../../../ui/Collapse'
5
6
  import { revisionRoleBadges } from '../../../shared/ResourceActionsBar'
6
7
  import type { WorkloadRevision, WorkloadPodInfo } from '../../../../types'
7
8
 
@@ -38,6 +39,9 @@ function podToneClass(pod: WorkloadPodInfo): string {
38
39
  // to that dialog — this is purely informational.
39
40
  export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }: ReplicaSetProgressionProps) {
40
41
  const [expanded, setExpanded] = useState<Set<number>>(new Set())
42
+ // Revision rows are mapped inline, so they can't each call useDisclosure;
43
+ // one generated prefix plus the revision number keeps aria-controls unique.
44
+ const panelBase = useId()
41
45
 
42
46
  const podsByHash = new Map<string, WorkloadPodInfo[]>()
43
47
  for (const pod of pods ?? []) {
@@ -59,17 +63,20 @@ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }
59
63
  <div className="space-y-1">
60
64
  {revisions.map((rev) => {
61
65
  const revPods = rev.podHash ? podsByHash.get(rev.podHash) ?? [] : []
62
- const isOpen = expanded.has(rev.number)
66
+ // One predicate for header, caret and panel: no pods, nothing to open.
67
+ const isOpen = expanded.has(rev.number) && revPods.length > 0
63
68
  return (
64
69
  <div key={rev.number} className="rounded border border-theme-border/50">
65
70
  <button
66
71
  type="button"
72
+ aria-expanded={isOpen}
73
+ aria-controls={disclosurePanelId(panelBase, rev.number)}
67
74
  onClick={() => toggle(rev.number)}
68
75
  className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-theme-hover"
69
76
  disabled={revPods.length === 0}
70
77
  >
71
78
  {revPods.length > 0 ? (
72
- isOpen ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" /> : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
79
+ <CollapseChevron open={isOpen} className="h-3.5 w-3.5" />
73
80
  ) : (
74
81
  <span className="w-3.5 shrink-0" />
75
82
  )}
@@ -85,7 +92,7 @@ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }
85
92
  <span className="shrink-0 text-xs tabular-nums text-theme-text-tertiary">{rev.replicas ?? 0} replicas</span>
86
93
  <span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(rev.createdAt)}</span>
87
94
  </button>
88
- {isOpen && revPods.length > 0 && (
95
+ <Collapse open={isOpen} mountLazily id={disclosurePanelId(panelBase, rev.number)}>
89
96
  <div className="space-y-0.5 border-t border-theme-border/50 px-2 py-1.5 pl-8">
90
97
  {revPods.map((pod) => (
91
98
  <div key={pod.name} className="flex items-center gap-2 py-0.5 text-xs">
@@ -107,7 +114,7 @@ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }
107
114
  </div>
108
115
  ))}
109
116
  </div>
110
- )}
117
+ </Collapse>
111
118
  </div>
112
119
  )
113
120
  })}
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent } from 'react'
2
- import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2, Upload, X } from 'lucide-react'
2
+ import { AlertTriangle, Check, Loader2, Upload, X } from 'lucide-react'
3
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
3
4
  import { ConfirmDialog } from '../ui/ConfirmDialog'
4
5
  import { DialogPortal } from '../ui/DialogPortal'
5
6
  import { YamlEditor, type YamlSchemaLoader } from '../ui/YamlEditor'
@@ -588,6 +589,7 @@ export function CreateResourceDialog({
588
589
 
589
590
  function ApplyErrorBanner({ error }: { error: string }) {
590
591
  const [expanded, setExpanded] = useState(false)
592
+ const { panelId, buttonProps } = useDisclosure(expanded)
591
593
  const parsed = formatApplyError(error)
592
594
  const hasFriendly = Boolean(parsed.suggestion)
593
595
  return (
@@ -603,19 +605,20 @@ function ApplyErrorBanner({ error }: { error: string }) {
603
605
  </div>
604
606
  {hasFriendly && (
605
607
  <button
608
+ {...buttonProps}
606
609
  type="button"
607
610
  onClick={() => setExpanded((value) => !value)}
608
611
  className="flex items-center gap-1 px-3 pb-2 text-red-500/60 hover:text-red-500/80 dark:text-red-400/60 dark:hover:text-red-400/80"
609
612
  >
610
- {expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
613
+ <CollapseChevron open={expanded} inheritColor className="h-3 w-3" />
611
614
  Details
612
615
  </button>
613
616
  )}
614
- {expanded && hasFriendly && (
617
+ <Collapse open={expanded && hasFriendly} id={panelId}>
615
618
  <div className="break-all px-3 pb-2 font-mono leading-relaxed text-red-500/60 dark:text-red-400/60">
616
619
  {parsed.raw}
617
620
  </div>
618
- )}
621
+ </Collapse>
619
622
  </div>
620
623
  )
621
624
  }
@@ -7,13 +7,13 @@ import {
7
7
  CheckCircle,
8
8
  Clock,
9
9
  RefreshCw,
10
- ChevronRight,
11
10
  Plus,
12
11
  Trash2,
13
12
  Shield,
14
13
  X,
15
14
  } from 'lucide-react'
16
15
  import { clsx } from 'clsx'
16
+ import { Collapse, CollapseChevron } from '../ui/Collapse'
17
17
  import { DiffViewer, DiffBadge } from './DiffViewer'
18
18
  import { TimelineToolbar } from './TimelineToolbar'
19
19
  import {
@@ -696,17 +696,20 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
696
696
  aria-label={expanded ? 'Collapse changes' : 'Expand changes'}
697
697
  className="shrink-0 rounded p-0.5 text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary"
698
698
  >
699
- <ChevronRight className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} />
699
+ <CollapseChevron open={expanded} inheritColor className="w-4 h-4" />
700
700
  </button>
701
701
  )}
702
702
  </div>
703
703
 
704
- {/* Expanded details - only for items with diffs */}
705
- {expanded && hasExpandableContent && item.diff && (
706
- <div className="mt-3 pt-3 border-t-subtle">
707
- <div className="text-xs text-theme-text-tertiary mb-2">Changes:</div>
708
- <DiffViewer diff={item.diff} />
709
- </div>
704
+ {/* Expanded details - only for items with diffs. A list holds many
705
+ cards, each diff is real work to render: mount on first open. */}
706
+ {hasExpandableContent && item.diff && (
707
+ <Collapse open={expanded} mountLazily>
708
+ <div className="mt-3 pt-3 border-t-subtle">
709
+ <div className="text-xs text-theme-text-tertiary mb-2">Changes:</div>
710
+ <DiffViewer diff={item.diff} />
711
+ </div>
712
+ </Collapse>
710
713
  )}
711
714
  </div>
712
715
  </div>
@@ -826,12 +829,12 @@ function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle
826
829
  aria-label={expanded ? 'Collapse occurrences' : 'Expand occurrences'}
827
830
  className="shrink-0 rounded p-0.5 text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary"
828
831
  >
829
- <ChevronRight className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} />
832
+ <CollapseChevron open={expanded} inheritColor className="w-4 h-4" />
830
833
  </button>
831
834
  </div>
832
835
 
833
836
  {/* Expanded details */}
834
- {expanded && (
837
+ <Collapse open={expanded}>
835
838
  <div className="mt-3 pt-3 border-t-subtle space-y-3">
836
839
  {/* First occurrence */}
837
840
  <div className="flex items-start gap-2">
@@ -865,7 +868,7 @@ function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle
865
868
  </div>
866
869
  </div>
867
870
  </div>
868
- )}
871
+ </Collapse>
869
872
  </div>
870
873
  </div>
871
874
  )
@@ -26,6 +26,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
26
26
  import { clsx } from 'clsx'
27
27
  import { Calendar, ChevronDown, ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react'
28
28
  import { Tooltip } from '../ui/Tooltip'
29
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
30
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
29
31
  import {
30
32
  barHeight,
31
33
  clampLensToSelection,
@@ -100,6 +102,7 @@ function DateTimeField({ label, valueMs, onChange, nowMs }: {
100
102
  nowMs: number
101
103
  }) {
102
104
  const [open, setOpen] = useState(false)
105
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
103
106
  const rootRef = useRef<HTMLDivElement>(null)
104
107
  const value = new Date(valueMs)
105
108
  const [viewY, setViewY] = useState(() => value.getFullYear())
@@ -145,9 +148,16 @@ function DateTimeField({ label, valueMs, onChange, nowMs }: {
145
148
  {fieldStamp(valueMs)}
146
149
  <Calendar className="h-3 w-3 shrink-0 text-theme-text-tertiary" />
147
150
  </button>
148
- {open && (
151
+ {shouldRender && (
149
152
  <div
150
- className="absolute left-0 top-full z-[60] mt-1 w-[232px] rounded-lg border border-theme-border bg-theme-surface p-2.5 shadow-theme-lg"
153
+ inert={!open}
154
+ className={clsx(
155
+ 'absolute left-0 top-full z-[60] mt-1 w-[232px] origin-top-left rounded-lg border border-theme-border bg-theme-surface p-2.5 shadow-theme-lg',
156
+ TRANSITION_MENU,
157
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
158
+ !open && 'pointer-events-none',
159
+ )}
160
+ style={overlayTransitionStyle(isOpen, 'menu')}
151
161
  role="dialog"
152
162
  aria-label={`Pick ${label.toLowerCase()} date and time`}
153
163
  >
@@ -294,6 +304,7 @@ export function TimelineStrip({
294
304
  const trackRef = useRef<HTMLDivElement>(null)
295
305
  const [width, setWidth] = useState(800)
296
306
  const [pickerOpen, setPickerOpen] = useState(false)
307
+ const { shouldRender: pickerRender, isOpen: pickerShown } = useAnimatedUnmount(pickerOpen, overlayExitMs('menu'))
297
308
  const [customFromMs, setCustomFromMs] = useState(0)
298
309
  const [customToMs, setCustomToMs] = useState(0)
299
310
 
@@ -528,11 +539,22 @@ export function TimelineStrip({
528
539
  <ChevronDown className="h-3 w-3 text-theme-text-tertiary" />
529
540
  </button>
530
541
  </Tooltip>
531
- {pickerOpen && (
542
+ {pickerRender && (
532
543
  // NO overflow-hidden: the DateTimeField calendars pop past the
533
544
  // dialog's bottom edge and were getting clipped to a header sliver.
534
545
  // The footer rounds its own bottom corners instead.
535
- <div className="absolute left-0 top-full z-50 mt-2 w-[460px] max-w-[92vw] rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg" role="dialog" aria-label="Time range picker">
546
+ <div
547
+ inert={!pickerOpen}
548
+ className={clsx(
549
+ 'absolute left-0 top-full z-50 mt-2 w-[460px] max-w-[92vw] origin-top-left rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg',
550
+ TRANSITION_MENU,
551
+ pickerShown ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
552
+ !pickerOpen && 'pointer-events-none',
553
+ )}
554
+ style={overlayTransitionStyle(pickerShown, 'menu')}
555
+ role="dialog"
556
+ aria-label="Time range picker"
557
+ >
536
558
  <p className="border-b border-theme-border/60 px-4 py-2.5 text-[11px] leading-relaxed text-theme-text-secondary">
537
559
  How much history to load. The blue band zooms into a slice of it.
538
560
  </p>
@@ -43,6 +43,7 @@ import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../.
43
43
  import { DiffViewer } from './DiffViewer'
44
44
  import { getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
45
45
  import { MiddleEllipsis } from '../ui/MiddleEllipsis'
46
+ import { CollapseChevron } from '../ui/Collapse'
46
47
  import { Tooltip } from '../ui/Tooltip'
47
48
  import { ResourceRefBadge } from '../ui/drawer-components'
48
49
  import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isProblematicEvent, laneTrackEvents, isChildVisibleInWindow, collidingLaneKeys, laneCollisionKey, type ResourceLane as BaseResourceLane, type TimelineGrouping, type PinnedLaneRef } from '../../utils/resource-hierarchy'
@@ -1644,10 +1645,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1644
1645
  aria-label={isExpanded ? 'Collapse' : 'Expand'}
1645
1646
  className="p-1 -m-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
1646
1647
  >
1647
- <ChevronRight className={clsx(
1648
- 'w-4 h-4 transition-transform',
1649
- isExpanded && 'rotate-90'
1650
- )} />
1648
+ <CollapseChevron open={isExpanded} inheritColor className="w-4 h-4" />
1651
1649
  </button>
1652
1650
  ) : compact ? null : (
1653
1651
  <div className="w-4" />
@@ -2285,7 +2283,7 @@ function ChildLaneLabel({ kind, group, showGroupChip, kindTitle, name, labelWidt
2285
2283
  style={{ left: selfRailPx }}
2286
2284
  aria-label={expanded ? 'Collapse' : 'Expand'}
2287
2285
  >
2288
- <ChevronRight className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} />
2286
+ <CollapseChevron open={!!expanded} inheritColor className="w-4 h-4" />
2289
2287
  </button>
2290
2288
  )}
2291
2289
  {/* Single-line child row: kind chip · name (middle-ellipsis). Colored chips
@@ -18,6 +18,8 @@ import {
18
18
  import { SearchBox } from '../ui/SearchBox'
19
19
  import { type ShortcutScope } from '../../hooks/useKeyboardShortcuts'
20
20
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
21
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
22
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
21
23
  import { pluralize } from '../../utils/pluralize'
22
24
  import type { TimelineEvent, TimeRange } from '../../types'
23
25
  import type { TimelineGrouping } from '../../utils/resource-hierarchy'
@@ -475,6 +477,7 @@ function SegmentedRadioGroup<T extends string>({
475
477
  */
476
478
  export function ViewMenu({ viewOptions }: { viewOptions: TimelineViewOptions }) {
477
479
  const [open, setOpen] = useState(false)
480
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
478
481
  const rootRef = useRef<HTMLDivElement | null>(null)
479
482
  usePopoverDismiss(open, setOpen, rootRef)
480
483
 
@@ -497,11 +500,18 @@ export function ViewMenu({ viewOptions }: { viewOptions: TimelineViewOptions })
497
500
  <ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
498
501
  </button>
499
502
 
500
- {open && (
503
+ {shouldRender && (
501
504
  <div
502
505
  role="menu"
503
506
  aria-label="View options"
504
- className="absolute right-0 top-full z-50 mt-1 min-w-[14rem] rounded-lg border border-theme-border bg-theme-elevated p-2 shadow-theme-lg"
507
+ inert={!open}
508
+ className={clsx(
509
+ 'absolute right-0 top-full z-50 mt-1 min-w-[14rem] origin-top-right rounded-lg border border-theme-border bg-theme-elevated p-2 shadow-theme-lg',
510
+ TRANSITION_MENU,
511
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
512
+ !open && 'pointer-events-none',
513
+ )}
514
+ style={overlayTransitionStyle(isOpen, 'menu')}
505
515
  >
506
516
  <span className="block px-2 pb-1 text-[10px] font-bold uppercase tracking-[0.08em] text-theme-text-tertiary">Sort</span>
507
517
  <SegmentedRadioGroup
@@ -634,10 +644,11 @@ function usePopoverDismiss(
634
644
  */
635
645
  function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuProps) {
636
646
  const [open, setOpen] = useState(false)
647
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
637
648
  const [search, setSearch] = useState('')
638
649
  const rootRef = useRef<HTMLDivElement | null>(null)
639
650
  const panelRef = useRef<HTMLDivElement | null>(null)
640
- const [anchor, setAnchor] = useState<{ left: number; top: number; maxHeight: number } | null>(null)
651
+ const [anchor, setAnchor] = useState<{ left: number; top: number; maxHeight: number; flipUp: boolean } | null>(null)
641
652
  const activeCount = kindFilter.length
642
653
  const selected = useMemo(() => new Set(kindFilter), [kindFilter])
643
654
 
@@ -645,9 +656,11 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
645
656
  // clips absolutely-positioned children. Portal the popover to the body and pin
646
657
  // it to the button with fixed coords so it escapes the clip. Dismiss must treat
647
658
  // BOTH the chip and the portaled panel as "inside".
659
+ // The anchor is kept through the exit transition (clearing it at logical
660
+ // close would unmount the panel mid-fade) and re-placed on the next open.
648
661
  const PANEL_WIDTH = 256
649
662
  useLayoutEffect(() => {
650
- if (!open) { setAnchor(null); return }
663
+ if (!open) return
651
664
  const place = () => {
652
665
  const r = rootRef.current?.getBoundingClientRect()
653
666
  if (!r) return
@@ -666,11 +679,13 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
666
679
  const top = flipUp
667
680
  ? Math.max(4, r.top - 4 - Math.min(panelH, maxHeight))
668
681
  : r.bottom + 4
669
- setAnchor({ left, top, maxHeight })
682
+ setAnchor({ left, top, maxHeight, flipUp })
670
683
  }
671
684
  place()
672
685
  // Re-place after the panel mounts so the first open can measure its height and
673
- // flip; the initial pass runs with panelH=0 (panel not yet in the DOM).
686
+ // flip; the initial pass runs with panelH=0 (panel not yet in the DOM). The
687
+ // presence hook mounts the panel a tick after `open`, hence `shouldRender` in
688
+ // the deps: the pass that runs once it is in the DOM does the real measurement.
674
689
  const raf = requestAnimationFrame(place)
675
690
  window.addEventListener('scroll', place, true)
676
691
  window.addEventListener('resize', place)
@@ -679,7 +694,7 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
679
694
  window.removeEventListener('scroll', place, true)
680
695
  window.removeEventListener('resize', place)
681
696
  }
682
- }, [open])
697
+ }, [open, shouldRender])
683
698
 
684
699
  useEffect(() => {
685
700
  if (!open) return
@@ -731,11 +746,29 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
731
746
  <ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
732
747
  </button>
733
748
 
734
- {open && anchor && createPortal(
749
+ {shouldRender && anchor && createPortal(
735
750
  <div
736
751
  ref={panelRef}
737
- style={{ position: 'fixed', left: anchor.left, top: anchor.top, width: PANEL_WIDTH, maxHeight: anchor.maxHeight }}
738
- className="z-50 overflow-y-auto rounded-md border border-theme-border bg-theme-surface shadow-theme-lg"
752
+ inert={!open}
753
+ style={{
754
+ position: 'fixed',
755
+ left: anchor.left,
756
+ top: anchor.top,
757
+ width: PANEL_WIDTH,
758
+ maxHeight: anchor.maxHeight,
759
+ ...overlayTransitionStyle(isOpen, 'menu'),
760
+ }}
761
+ className={clsx(
762
+ 'z-50 overflow-y-auto rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
763
+ anchor.flipUp ? 'origin-bottom-left' : 'origin-top-left',
764
+ TRANSITION_MENU,
765
+ isOpen
766
+ ? 'opacity-100 translate-y-0 scale-100'
767
+ : anchor.flipUp
768
+ ? 'opacity-0 translate-y-1 scale-[0.97]'
769
+ : 'opacity-0 -translate-y-1 scale-[0.97]',
770
+ !open && 'pointer-events-none',
771
+ )}
739
772
  >
740
773
  <MultiSelectPicker
741
774
  items={kindOptions}
@@ -173,8 +173,10 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
173
173
  // a multi-port Service's other ports are visible without opening the detail
174
174
  // page. Formatting (hide targetPort when it matches port) mirrors
175
175
  // ServicePortCards in ServiceRenderer.tsx so a Service's ports read the same
176
- // whether glanced at in the graph or opened in the full resource view.
177
- function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
176
+ // whether glanced at in the graph or opened in the full resource view. Exported
177
+ // because the GitOps resource tree's Service node summarises ports the same way
178
+ // and needs the same hover to reach the ones the summary drops.
179
+ export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
178
180
  if (ports.length < 2) return null;
179
181
  return (
180
182
  <div className="max-w-xs space-y-0.5">
@@ -3,6 +3,8 @@ import { FolderTree, ShieldCheck, ChevronDown, Check } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { TopologyMode, GroupingMode } from '../../types/core'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
7
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
6
8
 
7
9
  interface TopologyControlsProps {
8
10
  viewMode: TopologyMode
@@ -37,6 +39,7 @@ export function TopologyControls({
37
39
  leadingSlot,
38
40
  }: TopologyControlsProps) {
39
41
  const [groupOpen, setGroupOpen] = useState(false)
42
+ const { shouldRender: groupRender, isOpen: groupShown } = useAnimatedUnmount(groupOpen, overlayExitMs('menu'))
40
43
  const groupRef = useRef<HTMLDivElement>(null)
41
44
  const groupTriggerRef = useRef<HTMLButtonElement>(null)
42
45
  const groupItemRefs = useRef<(HTMLButtonElement | null)[]>([])
@@ -53,20 +56,25 @@ export function TopologyControls({
53
56
  if (restoreFocus) groupTriggerRef.current?.focus()
54
57
  }, [])
55
58
 
56
- // On open, move focus onto the active option so the menu is keyboard-navigable
57
- // (parity with the native <select> this replaced). Click-outside closes it.
59
+ // On open, move focus onto the active option so the menu is keyboard-navigable.
60
+ // The items exist only once the presence hook has mounted the menu, so this
61
+ // waits for `groupRender`.
58
62
  useEffect(() => {
59
- if (!groupOpen) return
63
+ if (!groupOpen || !groupRender) return
60
64
  const active = Math.max(0, groupOptions.findIndex((o) => o.value === groupingMode))
61
65
  groupItemRefs.current[active]?.focus()
66
+ // groupOptions/groupingMode are read once at open; re-running on their
67
+ // identity change would steal focus mid-interaction.
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [groupOpen, groupRender])
70
+
71
+ useEffect(() => {
72
+ if (!groupOpen) return
62
73
  const onDown = (e: MouseEvent) => {
63
74
  if (groupRef.current && !groupRef.current.contains(e.target as Node)) setGroupOpen(false)
64
75
  }
65
76
  document.addEventListener('mousedown', onDown)
66
77
  return () => document.removeEventListener('mousedown', onDown)
67
- // groupOptions/groupingMode are read once at open; re-running on their
68
- // identity change would steal focus mid-interaction.
69
- // eslint-disable-next-line react-hooks/exhaustive-deps
70
78
  }, [groupOpen])
71
79
 
72
80
  const onGroupMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
@@ -123,8 +131,19 @@ export function TopologyControls({
123
131
  {currentGroupLabel}
124
132
  <ChevronDown className="w-3 h-3 text-theme-text-tertiary" />
125
133
  </button>
126
- {groupOpen && (
127
- <div role="menu" onKeyDown={onGroupMenuKey} className="absolute right-0 top-full mt-1 z-50 min-w-[160px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl">
134
+ {groupRender && (
135
+ <div
136
+ role="menu"
137
+ onKeyDown={onGroupMenuKey}
138
+ inert={!groupOpen}
139
+ className={clsx(
140
+ 'absolute right-0 top-full mt-1 z-50 min-w-[160px] origin-top-right rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
141
+ TRANSITION_MENU,
142
+ groupShown ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
143
+ !groupOpen && 'pointer-events-none',
144
+ )}
145
+ style={overlayTransitionStyle(groupShown, 'menu')}
146
+ >
128
147
  {groupOptions.map((o, idx) => (
129
148
  <button
130
149
  key={o.value}
@@ -23,6 +23,7 @@ import { toCanvas } from 'html-to-image'
23
23
 
24
24
  import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Info, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
25
25
  import { PaneLoader } from '../ui/PaneLoader'
26
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
26
27
  import { TopologyOverlayBar } from './TopologyOverlayBar'
27
28
  import { Input } from '../ui/Input'
28
29
  import { Tooltip } from '../ui/Tooltip'
@@ -315,6 +316,8 @@ export function TopologyGraph({
315
316
  const [fitViewCounter, setFitViewCounter] = useState(0)
316
317
  const [isExporting, setIsExporting] = useState(false)
317
318
  const [showLegend, setShowLegend] = useState(false)
319
+ const [warningsOpen, setWarningsOpen] = useState(false)
320
+ const warningsDisclosure = useDisclosure(warningsOpen)
318
321
  const prevStructureRef = useRef<string>('')
319
322
  const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
320
323
  // Saved node positions for preservation across topology updates.
@@ -1064,10 +1067,16 @@ export function TopologyGraph({
1064
1067
  ? 'Large retained run history is summarized to keep the graph readable.'
1065
1068
  : 'Some resources failed to load. Data may be incomplete.'}
1066
1069
  </span>
1067
- <details className="mt-1">
1068
- <summary className="text-xs text-amber-400/80 hover:text-amber-400">
1069
- Show details ({topology.warnings.length})
1070
- </summary>
1070
+ <button
1071
+ {...warningsDisclosure.buttonProps}
1072
+ type="button"
1073
+ onClick={() => setWarningsOpen((v) => !v)}
1074
+ className="mt-1 flex items-center gap-1 text-xs text-amber-400/80 hover:text-amber-400"
1075
+ >
1076
+ <CollapseChevron open={warningsOpen} inheritColor className="h-3 w-3" />
1077
+ Show details ({topology.warnings.length})
1078
+ </button>
1079
+ <Collapse open={warningsOpen} id={warningsDisclosure.panelId}>
1071
1080
  <ul className="mt-1 text-xs text-theme-text-tertiary space-y-0.5">
1072
1081
  {rbacWarnings.length > 0 && otherWarnings.length > 0 && (
1073
1082
  <li className="text-amber-400/60 font-medium mt-1">RBAC restrictions:</li>
@@ -1088,7 +1097,7 @@ export function TopologyGraph({
1088
1097
  <li key={`other-${i}`} className="font-mono">{w}</li>
1089
1098
  ))}
1090
1099
  </ul>
1091
- </details>
1100
+ </Collapse>
1092
1101
  </div>
1093
1102
  </div>
1094
1103
  </div>
@@ -4,7 +4,8 @@ import { ReachActions, JustTestedNote, CopyableCommand, completedRequestMode, ty
4
4
  import { AlertBanner } from '../ui/drawer-components'
5
5
  import { PaneLoader } from '../ui/PaneLoader'
6
6
  import { ReachabilityGraph, MarkGlyph } from './ReachabilityGraph'
7
- import { Activity, ChevronRight } from 'lucide-react'
7
+ import { Activity } from 'lucide-react'
8
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
8
9
  import { Tooltip } from '../ui/Tooltip'
9
10
  import { buildGraph, noteHeadline } from './reachGraphModel'
10
11
  import { buildOrigins, defaultOrigin, probeCheckStats, type Origin, type OriginId } from './reachOrigins'
@@ -778,10 +779,10 @@ function untestableSummary(out: Origin[]): string {
778
779
  // ---------------------------------------------------------------- inspector
779
780
 
780
781
  /**
781
- * The panel's one disclosure, matching drawer-components' Section: a rotating
782
- * ChevronRight and a grid-template-rows transition. Hand-rolled "›"/"⌄" toggles
783
- * neither looked like the rest of Radar nor animated, so a section appearing was
784
- * an abrupt jump with no affordance saying it could be opened at all.
782
+ * The panel's one disclosure, on the shared Collapse primitive like
783
+ * drawer-components' Section. Hand-rolled "›"/"⌄" toggles neither looked like
784
+ * the rest of Radar nor animated, so a section appearing was an abrupt jump
785
+ * with no affordance saying it could be opened at all.
785
786
  */
786
787
  function Disclosure({
787
788
  open,
@@ -796,21 +797,22 @@ function Disclosure({
796
797
  count?: number
797
798
  children: React.ReactNode
798
799
  }) {
800
+ const { panelId, buttonProps } = useDisclosure(open)
799
801
  return (
800
802
  <div>
801
803
  <button
804
+ {...buttonProps}
802
805
  type="button"
803
806
  onClick={onToggle}
804
- aria-expanded={open}
805
807
  className="flex w-full items-center gap-1.5 text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
806
808
  >
807
- <ChevronRight className={`h-3 w-3 flex-none transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
809
+ <CollapseChevron open={open} className="h-3 w-3 flex-none" />
808
810
  <span>{label}</span>
809
811
  {count !== undefined && <span className="flex-1 text-right font-normal tracking-normal">{count}</span>}
810
812
  </button>
811
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
812
- <div className="overflow-hidden">{children}</div>
813
- </div>
813
+ <Collapse open={open} id={panelId}>
814
+ {children}
815
+ </Collapse>
814
816
  </div>
815
817
  )
816
818
  }
@@ -981,6 +983,7 @@ function HopSection({
981
983
  }) {
982
984
  const [openOverride, setOpenOverride] = useState<boolean | null>(null)
983
985
  const open = openOverride ?? hop.expanded
986
+ const { panelId, buttonProps } = useDisclosure(open)
984
987
  const ref = useRef<HTMLDivElement>(null)
985
988
  // A click on the graph scrolls its hop into view instead of replacing the
986
989
  // panel - the gesture still means "I care about this one", it just no longer
@@ -1012,15 +1015,15 @@ function HopSection({
1012
1015
  className={`mt-2 rounded-md px-2 py-1 transition-colors duration-200 ${selected ? 'selection selection-ring' : ''}`}
1013
1016
  >
1014
1017
  <button
1018
+ {...buttonProps}
1015
1019
  type="button"
1016
1020
  onClick={() => {
1017
1021
  setOpenOverride(!open)
1018
1022
  onSelect(hop.id)
1019
1023
  }}
1020
- aria-expanded={open}
1021
1024
  className="flex w-full items-center gap-1.5 text-left"
1022
1025
  >
1023
- <ChevronRight className={`h-3 w-3 flex-none text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
1026
+ <CollapseChevron open={open} className="h-3 w-3 flex-none" />
1024
1027
  <span className="text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{hop.kind}</span>
1025
1028
  <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{hop.name}</span>
1026
1029
  <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[hop.chipTone]}`}>{hop.chipText || HOP_STATE_NOTE[hop.state]}</span>
@@ -1032,8 +1035,7 @@ function HopSection({
1032
1035
  .join(' — ')}
1033
1036
  </div>
1034
1037
  )}
1035
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
1036
- <div className="overflow-hidden">
1038
+ <Collapse open={open} id={panelId}>
1037
1039
  <div className="pb-1 pl-[18px]">
1038
1040
  {hop.openRef?.name && (
1039
1041
  <button type="button" onClick={() => onOpen(hop.openRef!)} className="text-[11px] text-accent-text hover:underline">
@@ -1084,8 +1086,7 @@ function HopSection({
1084
1086
  <Caveats items={hop.notProve} />
1085
1087
  </div>
1086
1088
  </div>
1087
- </div>
1088
- </div>
1089
+ </Collapse>
1089
1090
  </div>
1090
1091
  )
1091
1092
  }