@skyhook-io/k8s-ui 1.14.6 → 1.14.7

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 (45) 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/issues/IssuesView.tsx +35 -54
  10. package/src/components/logs/LogCore.tsx +40 -9
  11. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  12. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  13. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  14. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  15. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  16. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  17. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  18. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  19. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  20. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  21. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  22. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +13 -6
  23. package/src/components/shared/CreateResourceDialog.tsx +7 -4
  24. package/src/components/timeline/TimelineList.tsx +14 -11
  25. package/src/components/timeline/TimelineStrip.tsx +26 -4
  26. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  27. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  28. package/src/components/topology/TopologyControls.tsx +27 -8
  29. package/src/components/topology/TopologyGraph.tsx +14 -5
  30. package/src/components/trace/ReachabilityView.tsx +17 -16
  31. package/src/components/ui/CardSection.tsx +79 -35
  32. package/src/components/ui/Collapse.test.tsx +74 -0
  33. package/src/components/ui/Collapse.tsx +115 -29
  34. package/src/components/ui/DialogPortal.tsx +7 -2
  35. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  36. package/src/components/ui/RestrictedState.tsx +7 -4
  37. package/src/components/ui/RowActionMenu.tsx +20 -8
  38. package/src/components/ui/SelectMenu.tsx +18 -6
  39. package/src/components/ui/YamlEditor.tsx +7 -9
  40. package/src/components/ui/drawer-components.tsx +14 -21
  41. package/src/components/ui/index.ts +30 -22
  42. package/src/components/workload/WorkloadView.tsx +6 -10
  43. package/src/hooks/useAnimatedUnmount.ts +21 -3
  44. package/src/theme/components.css +4 -1
  45. package/src/utils/animation.ts +93 -13
@@ -1,7 +1,8 @@
1
- import { useState, useMemo } from 'react'
2
- import { ShieldCheck, ChevronDown, ChevronRight, CheckCircle2, XCircle } from 'lucide-react'
1
+ import { useState, useMemo, useId } from 'react'
2
+ import { ShieldCheck, CheckCircle2, XCircle } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../../ui/Collapse'
5
6
  import { formatAge } from '../resource-utils'
6
7
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER, TrivyAlertBanner } from './trivy-shared'
7
8
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
@@ -65,6 +66,10 @@ function groupChecks(checks: any[]): GroupedCheck[] {
65
66
  export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererProps) {
66
67
  const [expandedSection, setExpandedSection] = useState(true)
67
68
  const [expandedChecks, setExpandedChecks] = useState<Set<string>>(new Set())
69
+ const sectionDisclosure = useDisclosure(expandedSection)
70
+ // Check rows are mapped inline, so they can't each call useDisclosure; one
71
+ // generated prefix plus the check id keeps aria-controls unique.
72
+ const checkPanelBase = useId()
68
73
 
69
74
  const report = data.report || {}
70
75
  const summary = report.summary || {}
@@ -149,19 +154,22 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
149
154
  {grouped.length > 0 && (
150
155
  <Section title="Checks">
151
156
  <button
157
+ {...sectionDisclosure.buttonProps}
152
158
  onClick={() => setExpandedSection(!expandedSection)}
153
159
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
154
160
  >
155
- {expandedSection ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
161
+ <CollapseChevron open={expandedSection} className="w-3.5 h-3.5" />
156
162
  {grouped.length} unique checks{containerCount > 0 ? ` (${containerCount} containers scanned)` : ''}
157
163
  </button>
158
- {expandedSection && (
164
+ <Collapse open={expandedSection} unmountOnExit id={sectionDisclosure.panelId}>
159
165
  <div className="space-y-0.5">
160
166
  {grouped.map((group) => {
161
167
  const isExpanded = expandedChecks.has(group.checkID)
162
168
  return (
163
169
  <div key={group.checkID}>
164
170
  <button
171
+ aria-expanded={isExpanded}
172
+ aria-controls={disclosurePanelId(checkPanelBase, group.checkID)}
165
173
  onClick={() => toggleCheck(group.checkID)}
166
174
  className="w-full flex items-center gap-2 py-1.5 px-1 rounded hover:bg-theme-hover/50 text-left"
167
175
  >
@@ -177,13 +185,9 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
177
185
  {group.count > 1 && (
178
186
  <span className="text-[10px] text-theme-text-tertiary shrink-0">{group.count} containers</span>
179
187
  )}
180
- {isExpanded ? (
181
- <ChevronDown className="w-3 h-3 text-theme-text-tertiary shrink-0" />
182
- ) : (
183
- <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
184
- )}
188
+ <CollapseChevron open={isExpanded} className="w-3 h-3" />
185
189
  </button>
186
- {isExpanded && (
190
+ <Collapse open={isExpanded} mountLazily id={disclosurePanelId(checkPanelBase, group.checkID)}>
187
191
  <div className="ml-6 mr-1 mb-2 p-2.5 bg-theme-elevated/50 rounded border border-theme-border/50 space-y-2">
188
192
  <div className="text-[10px] font-mono text-theme-text-tertiary">{group.checkID}</div>
189
193
  {group.description && (
@@ -209,12 +213,12 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
209
213
  </div>
210
214
  )}
211
215
  </div>
212
- )}
216
+ </Collapse>
213
217
  </div>
214
218
  )
215
219
  })}
216
220
  </div>
217
- )}
221
+ </Collapse>
218
222
  </Section>
219
223
  )}
220
224
  </>
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
- import { KeyRound, ChevronDown, ChevronRight } from 'lucide-react'
2
+ import { KeyRound } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { formatAge } from '../resource-utils'
6
7
  import { SEVERITY_BADGE_COLORS, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
7
8
  import { pluralize } from '../../../utils/pluralize'
@@ -16,6 +17,7 @@ const INITIAL_SHOW_COUNT = 50
16
17
  export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendererProps) {
17
18
  const [showAll, setShowAll] = useState(false)
18
19
  const [expanded, setExpanded] = useState(true)
20
+ const { panelId, buttonProps } = useDisclosure(expanded)
19
21
 
20
22
  const report = data.report || {}
21
23
  const summary = report.summary || {}
@@ -70,13 +72,17 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
70
72
  {secrets.length > 0 && (
71
73
  <Section title="Exposed Secrets">
72
74
  <button
75
+ {...buttonProps}
73
76
  onClick={() => setExpanded(!expanded)}
74
77
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
75
78
  >
76
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
79
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
77
80
  {pluralize(secrets.length, 'secret')}
78
81
  </button>
79
- {expanded && (
82
+ {/* The table can run to hundreds of rows; unmount it while closed
83
+ so a collapsed report stays cheap. Filters live above, so
84
+ nothing is lost. */}
85
+ <Collapse open={expanded} unmountOnExit id={panelId}>
80
86
  <div className="overflow-x-auto -mx-1">
81
87
  <table className="w-full text-xs">
82
88
  <thead>
@@ -113,7 +119,7 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
113
119
  </button>
114
120
  )}
115
121
  </div>
116
- )}
122
+ </Collapse>
117
123
  </Section>
118
124
  )}
119
125
  </>
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
- import { Info, CheckCircle, XCircle, AlertCircle, ChevronRight, ChevronDown, FileCode, AlertTriangle, Layers } from 'lucide-react'
2
+ import { Info, CheckCircle, XCircle, AlertCircle, FileCode, AlertTriangle, Layers } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { getGenericResourceStatus } from '../generic-status'
6
7
  import type { HealthLevel } from '../resource-utils'
7
8
 
@@ -244,19 +245,23 @@ interface NestedObjectViewerProps {
244
245
 
245
246
  function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps) {
246
247
  const [expanded, setExpanded] = useState(depth < 1)
248
+ const { panelId, buttonProps } = useDisclosure(expanded)
247
249
 
248
250
  if (Array.isArray(value)) {
249
251
  return (
250
252
  <div className="text-sm">
251
253
  <button
254
+ {...buttonProps}
252
255
  onClick={() => setExpanded(!expanded)}
253
256
  className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
254
257
  >
255
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
258
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
256
259
  <span className="font-medium">{name}</span>
257
260
  <span className="text-xs text-theme-text-tertiary">({value.length} items)</span>
258
261
  </button>
259
- {expanded && (
262
+ {/* Recursive and unbounded: closed branches unmount so a deep object
263
+ costs only the rows the user has actually opened. */}
264
+ <Collapse open={expanded} unmountOnExit id={panelId}>
260
265
  <div className="ml-5 mt-1 space-y-1">
261
266
  {value.map((item, i) => {
262
267
  if (typeof item === 'object' && item !== null) {
@@ -269,7 +274,7 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
269
274
  )
270
275
  })}
271
276
  </div>
272
- )}
277
+ </Collapse>
273
278
  </div>
274
279
  )
275
280
  }
@@ -282,14 +287,15 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
282
287
  return (
283
288
  <div className="text-sm">
284
289
  <button
290
+ {...buttonProps}
285
291
  onClick={() => setExpanded(!expanded)}
286
292
  className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
287
293
  >
288
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
294
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
289
295
  <span className="font-medium">{name}</span>
290
296
  <span className="text-xs text-theme-text-tertiary">({entries.length} fields)</span>
291
297
  </button>
292
- {expanded && (
298
+ <Collapse open={expanded} unmountOnExit id={panelId}>
293
299
  <div className="ml-5 mt-1 space-y-1">
294
300
  {simpleEntries.map(([k, v]) => (
295
301
  <div key={k} className="flex items-start gap-2 text-xs">
@@ -301,7 +307,7 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
301
307
  <NestedObjectViewer key={k} name={formatFieldName(k)} value={v} depth={depth + 1} />
302
308
  ))}
303
309
  </div>
304
- )}
310
+ </Collapse>
305
311
  </div>
306
312
  )
307
313
  }
@@ -1,8 +1,9 @@
1
1
  import type React from 'react'
2
- import { Shield, ShieldCheck, ShieldAlert, FileWarning, ListChecks, ChevronDown, ChevronRight } from 'lucide-react'
2
+ import { Shield, ShieldCheck, ShieldAlert, FileWarning, ListChecks } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { useState } from 'react'
5
5
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
6
+ import { Collapse, CollapseChevron } from '../../ui/Collapse'
6
7
  import {
7
8
  getPolicyReportSummary,
8
9
  getPolicyReportResults,
@@ -60,7 +61,7 @@ function ResultRow({ result }: { result: any }) {
60
61
  onClick={() => hasMessage && setExpanded(!expanded)}
61
62
  >
62
63
  {hasMessage ? (
63
- expanded ? <ChevronDown className="w-3 h-3 text-theme-text-tertiary shrink-0" /> : <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
64
+ <CollapseChevron open={expanded} className="w-3 h-3" />
64
65
  ) : (
65
66
  <span className="w-3 shrink-0" />
66
67
  )}
@@ -80,7 +81,8 @@ function ResultRow({ result }: { result: any }) {
80
81
  <span className="text-theme-text-tertiary truncate">/ {result.rule}</span>
81
82
  )}
82
83
  </div>
83
- {expanded && message && (
84
+ {/* A report can hold hundreds of rows; each detail renders on first open. */}
85
+ <Collapse open={expanded && hasMessage} mountLazily>
84
86
  <div className="px-2 pb-2 pl-7">
85
87
  <div className="text-xs text-theme-text-secondary break-all card-inner">
86
88
  {message}
@@ -97,7 +99,7 @@ function ResultRow({ result }: { result: any }) {
97
99
  </div>
98
100
  )}
99
101
  </div>
100
- )}
102
+ </Collapse>
101
103
  </div>
102
104
  )
103
105
  }
@@ -1,8 +1,8 @@
1
1
  import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { useState } from 'react'
4
- import { ChevronDown, ChevronRight } from 'lucide-react'
5
4
  import { Section } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
6
6
  import { SEVERITY_BADGE } from '../../../utils/badge-colors'
7
7
  import { isForbiddenError } from '../../../types/fetch-error'
8
8
  import { LookupFailureNote } from './LookupFailureNote'
@@ -829,6 +829,7 @@ function PassingGroup({
829
829
  }) {
830
830
  const [open, setOpen] = useState(false)
831
831
  const [showAll, setShowAll] = useState(false)
832
+ const { panelId, buttonProps } = useDisclosure(open)
832
833
 
833
834
  // Nothing to open: the count is real but the subjects did not survive the
834
835
  // server cap, so the label stands alone rather than offering an empty drawer.
@@ -843,14 +844,16 @@ function PassingGroup({
843
844
  return (
844
845
  <div className="pt-0.5">
845
846
  <button
847
+ {...buttonProps}
846
848
  type="button"
847
849
  onClick={() => setOpen(!open)}
848
850
  className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-secondary"
849
851
  >
850
- {open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
852
+ <CollapseChevron open={open} className="w-3 h-3" />
851
853
  {label}
852
854
  </button>
853
- {open && (
855
+ {/* One of these per rule; the subject list only renders once opened. */}
856
+ <Collapse open={open} mountLazily id={panelId}>
854
857
  <div className="space-y-1.5 mt-1.5">
855
858
  {visible.map((s, i) => (
856
859
  <SubjectRow
@@ -891,7 +894,7 @@ function PassingGroup({
891
894
  </div>
892
895
  )}
893
896
  </div>
894
- )}
897
+ </Collapse>
895
898
  </div>
896
899
  )
897
900
  }
@@ -1,7 +1,8 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { Bell, Search, ChevronRight } from 'lucide-react'
2
+ import { Bell, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { Input } from '../../ui/Input'
6
7
  import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
7
8
  import {
@@ -116,6 +117,7 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
116
117
 
117
118
  // Auto-expand when searching so matched rules are visible
118
119
  const expanded = searchTerm ? filteredRules.length > 0 : manualExpanded
120
+ const { panelId, buttonProps } = useDisclosure(expanded)
119
121
 
120
122
  // If searching and no matches, hide the group entirely
121
123
  if (searchTerm && filteredRules.length === 0) return null
@@ -123,11 +125,12 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
123
125
  return (
124
126
  <div className="card-inner">
125
127
  <button
128
+ {...buttonProps}
126
129
  onClick={() => setManualExpanded(!manualExpanded)}
127
130
  className="flex items-center justify-between w-full text-left"
128
131
  >
129
132
  <div className="flex items-center gap-2">
130
- <ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
133
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
131
134
  <span className="text-sm text-theme-text-primary font-medium">{group.name}</span>
132
135
  </div>
133
136
  <div className="flex items-center gap-2">
@@ -147,20 +150,15 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
147
150
  {group.recordCount > 0 && <span>{group.recordCount} recording</span>}
148
151
  </div>
149
152
  )}
150
- <div
151
- className="grid transition-[grid-template-rows] duration-200 ease-out"
152
- style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
153
- >
154
- <div className="overflow-hidden">
155
- <div className="mt-2 space-y-2">
156
- {filteredRules.map((rule, i) => (
157
- rule.type === 'alert'
158
- ? <AlertRuleCard key={`alert-${rule.alert}-${i}`} rule={rule} />
159
- : <RecordingRuleCard key={`rec-${rule.record}-${i}`} rule={rule} />
160
- ))}
161
- </div>
153
+ <Collapse open={expanded} id={panelId}>
154
+ <div className="mt-2 space-y-2">
155
+ {filteredRules.map((rule, i) => (
156
+ rule.type === 'alert'
157
+ ? <AlertRuleCard key={`alert-${rule.alert}-${i}`} rule={rule} />
158
+ : <RecordingRuleCard key={`rec-${rule.record}-${i}`} rule={rule} />
159
+ ))}
162
160
  </div>
163
- </div>
161
+ </Collapse>
164
162
  </div>
165
163
  )
166
164
  }
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { Package, ChevronDown, ChevronRight, Search } from 'lucide-react'
2
+ import { Package, Search } from 'lucide-react'
3
3
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
4
5
  import { Input } from '../../ui/Input'
5
6
  import { formatAge } from '../resource-utils'
6
7
  import { formatTrivyImage } from './trivy-shared'
@@ -14,6 +15,7 @@ const INITIAL_SHOW_COUNT = 100
14
15
  export function SbomReportRenderer({ data }: SbomReportRendererProps) {
15
16
  const [showAll, setShowAll] = useState(false)
16
17
  const [expanded, setExpanded] = useState(true)
18
+ const { panelId, buttonProps } = useDisclosure(expanded)
17
19
  const [searchTerm, setSearchTerm] = useState('')
18
20
 
19
21
  const report = data.report || {}
@@ -63,13 +65,17 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
63
65
  {bom.length > 0 && (
64
66
  <Section title="Components">
65
67
  <button
68
+ {...buttonProps}
66
69
  onClick={() => setExpanded(!expanded)}
67
70
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
68
71
  >
69
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
72
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
70
73
  {bom.length} components
71
74
  </button>
72
- {expanded && (
75
+ {/* The table can run to hundreds of rows; unmount it while closed
76
+ so a collapsed report stays cheap. Filters live above, so
77
+ nothing is lost. */}
78
+ <Collapse open={expanded} unmountOnExit id={panelId}>
73
79
  <div className="overflow-x-auto -mx-1">
74
80
  {/* Search */}
75
81
  <div className="flex items-center gap-2 mb-2 px-1">
@@ -131,7 +137,7 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
131
137
  </button>
132
138
  )}
133
139
  </div>
134
- )}
140
+ </Collapse>
135
141
  </Section>
136
142
  )}
137
143
  </>
@@ -1,7 +1,8 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { Shield, Package, ChevronDown, ChevronRight, ChevronUp, ExternalLink, Search, ArrowUpDown, Wrench } from 'lucide-react'
2
+ import { Shield, Package, ChevronDown, ChevronUp, ExternalLink, Search, ArrowUpDown, Wrench } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
6
  import { Input } from '../../ui/Input'
6
7
  import { formatAge } from '../resource-utils'
7
8
  import { SEVERITY_BADGE_COLORS, SEVERITY_BAR_COLORS, SEVERITY_TEXT_COLORS, SEVERITY_ORDER, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
@@ -27,6 +28,7 @@ const COLUMNS: { key: SortColumn; label: string }[] = [
27
28
  export function VulnerabilityReportRenderer({ data }: VulnerabilityReportRendererProps) {
28
29
  const [showAll, setShowAll] = useState(false)
29
30
  const [expanded, setExpanded] = useState(true)
31
+ const { panelId, buttonProps } = useDisclosure(expanded)
30
32
  const [searchTerm, setSearchTerm] = useState('')
31
33
  const [severityFilters, setSeverityFilters] = useState<Set<string>>(new Set())
32
34
  const [fixableOnly, setFixableOnly] = useState(false)
@@ -211,13 +213,17 @@ export function VulnerabilityReportRenderer({ data }: VulnerabilityReportRendere
211
213
  {vulnerabilities.length > 0 && (
212
214
  <Section title="Vulnerabilities" icon={Package}>
213
215
  <button
216
+ {...buttonProps}
214
217
  onClick={() => setExpanded(!expanded)}
215
218
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
216
219
  >
217
- {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
220
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
218
221
  {vulnerabilities.length} vulnerabilities
219
222
  </button>
220
- {expanded && (
223
+ {/* The table can run to hundreds of rows; unmount it while closed
224
+ so a collapsed report stays cheap. Filters live above, so
225
+ nothing is lost. */}
226
+ <Collapse open={expanded} unmountOnExit id={panelId}>
221
227
  <div className="overflow-x-auto -mx-1">
222
228
  {/* Search and filter toolbar */}
223
229
  <div className="flex items-center gap-2 mb-2 px-1">
@@ -339,7 +345,7 @@ export function VulnerabilityReportRenderer({ data }: VulnerabilityReportRendere
339
345
  </button>
340
346
  )}
341
347
  </div>
342
- )}
348
+ </Collapse>
343
349
  </Section>
344
350
  )}
345
351
  </>
@@ -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
  )