@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
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { readFileSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { Collapse, CollapseChevron, disclosurePanelId } from './Collapse'
6
+ import {
7
+ CSS_EASE,
8
+ DURATION_DISCLOSURE,
9
+ TRANSITION_CHEVRON,
10
+ TRANSITION_DISCLOSURE,
11
+ } from '../../utils/animation'
12
+
13
+ // The primitive is the app-wide standard for expand/collapse; these pin the
14
+ // contract other components rely on rather than the visuals.
15
+ describe('Collapse', () => {
16
+ it('animates grid-template-rows with the shared disclosure timing', () => {
17
+ const html = renderToString(<Collapse open={false}>x</Collapse>)
18
+ expect(html).toContain('grid-template-rows:0fr')
19
+ expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
20
+ expect(TRANSITION_DISCLOSURE).toContain(`duration-${DURATION_DISCLOSURE}`)
21
+ expect(TRANSITION_DISCLOSURE).toContain('motion-reduce:transition-none')
22
+ })
23
+
24
+ it('keeps closed content mounted but inert, and clipped', () => {
25
+ const html = renderToString(<Collapse open={false}>hidden-content</Collapse>)
26
+ expect(html).toContain('hidden-content')
27
+ expect(html).toMatch(/inert=""|inert>/)
28
+ expect(html).toContain('overflow-hidden')
29
+ const opened = renderToString(<Collapse open>shown</Collapse>)
30
+ expect(opened).toContain('grid-template-rows:1fr')
31
+ expect(opened).not.toMatch(/inert=""|inert>/)
32
+ })
33
+
34
+ it('mountLazily renders nothing until first open', () => {
35
+ expect(renderToString(<Collapse open={false} mountLazily>lazy</Collapse>)).not.toContain('lazy')
36
+ expect(renderToString(<Collapse open mountLazily>lazy</Collapse>)).toContain('lazy')
37
+ })
38
+
39
+ it('unmountOnExit renders nothing when initially closed', () => {
40
+ // Initial-closed mount never had a transition to wait for.
41
+ expect(renderToString(<Collapse open={false} unmountOnExit>gone</Collapse>)).not.toContain('gone')
42
+ expect(renderToString(<Collapse open unmountOnExit>here</Collapse>)).toContain('here')
43
+ })
44
+
45
+ it('exposes the panel id for aria-controls', () => {
46
+ expect(renderToString(<Collapse open id="panel-1">x</Collapse>)).toContain('id="panel-1"')
47
+ })
48
+
49
+ it('disclosurePanelId never yields whitespace (aria-controls is a space-separated list)', () => {
50
+ expect(disclosurePanelId(':r1:', 'Access Control')).toBe(':r1:-Access%20Control')
51
+ expect(disclosurePanelId(':r1:', 7)).toBe(':r1:-7')
52
+ // Injective: keys that only differ by the separator stay distinct.
53
+ expect(disclosurePanelId('p', 'a b')).not.toBe(disclosurePanelId('p', 'a_b'))
54
+ })
55
+ })
56
+
57
+ describe('CollapseChevron', () => {
58
+ it('runs the same clock as the panel', () => {
59
+ expect(TRANSITION_CHEVRON).toContain(`duration-${DURATION_DISCLOSURE}`)
60
+ const html = renderToString(<CollapseChevron open />)
61
+ expect(html).toContain('rotate-90')
62
+ expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
63
+ })
64
+ })
65
+
66
+ describe('motion tokens stay in step with the stylesheet', () => {
67
+ it('.issue-details-motion mirrors DURATION_DISCLOSURE and CSS_EASE', () => {
68
+ const css = readFileSync(join(__dirname, '../../theme/components.css'), 'utf8')
69
+ const m = css.match(/\.issue-details-motion \{[^}]*transition: grid-template-rows (\d+)ms ([^;]+);/)
70
+ expect(m, 'legacy class present').not.toBeNull()
71
+ expect(Number(m![1])).toBe(DURATION_DISCLOSURE)
72
+ expect(m![2].replace(/\s+/g, '')).toBe(CSS_EASE.replace(/\s+/g, ''))
73
+ })
74
+ })
@@ -1,47 +1,93 @@
1
- import { useState, type ReactNode } from 'react'
1
+ import { useEffect, useId, useState, type ReactNode, type TransitionEvent } from 'react'
2
2
  import { ChevronRight } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
+ import { DURATION_DISCLOSURE, TRANSITION_CHEVRON, TRANSITION_DISCLOSURE, prefersReducedMotion } from '../../utils/animation'
4
5
 
5
6
  // Animated show/hide using the grid-template-rows 0fr→1fr technique — the
6
- // app-wide standard for expand/collapse (drawers, checks, audit, resources
7
- // sidebar, port-forward). The inner overflow-hidden wrapper is load-bearing:
8
- // it clips the content while the grid row animates between 0 and its natural
9
- // height, so nothing spills before the row is fully open.
7
+ // app-wide standard for expand/collapse (drawer sections, checks, audit,
8
+ // resources sidebar, issue rows, and every disclosure in Radar Cloud). The
9
+ // inner overflow-hidden wrapper is load-bearing: it clips the content while
10
+ // the grid row animates between 0 and its natural height, so nothing spills
11
+ // before the row is fully open.
10
12
  //
11
- // Content stays mounted while closed (clipped to zero height) — that's what
12
- // lets it animate. `inert` when closed keeps that clipped content out of the
13
- // tab order and the accessibility tree, so keyboard/screen-reader users don't
14
- // land on invisible rows (matching ChecksView's collapse).
13
+ // Timing lives in utils/animation (DURATION_DISCLOSURE, one curve). The caret
14
+ // (CollapseChevron) runs the same clock, so an interrupted toggle reverses
15
+ // panel and caret together.
15
16
  //
16
- // `mountLazily` defers rendering the children until the first open, then keeps
17
- // them mounted so both the open and close animations still run. Use it when the
18
- // collapsed content is heavy and there are many instances (e.g. hundreds of
19
- // resource rows, each with a drift panel) — otherwise every collapsed instance
20
- // pays its render cost up front. Off by default so the common single-instance
21
- // case keeps the simplest behavior.
17
+ // Mount policy — pick per site, it is not cosmetic:
18
+ // default content stays mounted while closed, clipped to zero height
19
+ // and `inert` so keyboard/screen-reader users can't land on
20
+ // invisible rows. Right for static or cheap content.
21
+ // mountLazily defers the first render until the first open, then keeps
22
+ // it. For many instances with heavy collapsed content
23
+ // (hundreds of rows each with a drift panel).
24
+ // unmountOnExit unmounts the children once the close transition has
25
+ // finished (transitionend on this wrapper, with a timer
26
+ // fallback so disposal is guaranteed under reduced motion,
27
+ // for empty content, or if the event never fires). For
28
+ // subtrees that poll, subscribe, or should reset on close.
29
+ // Note: work stops when the subtree unmounts, i.e. after the
30
+ // exit; a query that must stop at logical close should also
31
+ // take `enabled: open`.
22
32
  export function Collapse({
23
33
  open,
24
34
  children,
25
35
  className,
26
36
  mountLazily = false,
37
+ unmountOnExit = false,
38
+ id,
27
39
  }: {
28
40
  open: boolean
29
41
  children: ReactNode
30
42
  className?: string
31
43
  mountLazily?: boolean
44
+ unmountOnExit?: boolean
45
+ /** Panel id for the header's aria-controls (see useDisclosure). */
46
+ id?: string
32
47
  }) {
33
48
  // Latch: once opened, stay mounted. Conditional setState-in-render is the
34
49
  // supported pattern for deriving state from props without an extra commit.
35
50
  const [hasOpened, setHasOpened] = useState(open)
36
51
  if (mountLazily && open && !hasOpened) setHasOpened(true)
37
- const render = !mountLazily || hasOpened
52
+
53
+ // unmountOnExit: keep rendering through the close transition, then stop.
54
+ // `exiting` must flip in the SAME render that sees `open` go false — doing
55
+ // it in an effect would commit one frame with the children already gone
56
+ // (render = open || exiting, both false) before the effect put them back.
57
+ // Same setState-in-render pattern as the latch above.
58
+ const [exiting, setExiting] = useState(false)
59
+ const [prevOpen, setPrevOpen] = useState(open)
60
+ if (open !== prevOpen) {
61
+ setPrevOpen(open)
62
+ // Reopen before disposal cancels the pending unmount; under reduced motion
63
+ // the stylesheet has zeroed the transition, so dispose at logical close.
64
+ setExiting(!open && unmountOnExit && !prefersReducedMotion())
65
+ }
66
+ // Guarantee disposal even if transitionend never arrives (empty content
67
+ // transitions instantly; a cancelled transition fires no end). Any change to
68
+ // `exiting` (reopen, transitionend) clears the pending timer via cleanup.
69
+ useEffect(() => {
70
+ if (!exiting) return
71
+ const t = window.setTimeout(() => setExiting(false), DURATION_DISCLOSURE + 50)
72
+ return () => window.clearTimeout(t)
73
+ }, [exiting])
74
+
75
+ const onTransitionEnd = (event: TransitionEvent<HTMLDivElement>) => {
76
+ if (!exiting) return
77
+ // Only this wrapper's own row transition — descendants' transitions
78
+ // bubble here too.
79
+ if (event.target !== event.currentTarget) return
80
+ if (event.propertyName !== 'grid-template-rows') return
81
+ setExiting(false)
82
+ }
83
+
84
+ const render = unmountOnExit ? open || exiting : !mountLazily || hasOpened
38
85
  return (
39
86
  <div
40
- className={clsx(
41
- 'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
42
- className,
43
- )}
87
+ id={id}
88
+ className={clsx('grid', TRANSITION_DISCLOSURE, className)}
44
89
  style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
90
+ onTransitionEnd={onTransitionEnd}
45
91
  >
46
92
  {/* `relative` is load-bearing. Collapsed content is still laid out at full
47
93
  height and only clipped by `overflow-hidden` — but an absolutely-positioned
@@ -51,24 +97,64 @@ export function Collapse({
51
97
  and extends the enclosing scroll container's scrollable overflow: the drawer
52
98
  scrolls past its own content into blank space. Anchoring here puts those
53
99
  boxes back inside the clip, so a collapsed section contributes nothing. */}
54
- <div className="relative overflow-hidden" inert={!open || undefined}>{render ? children : null}</div>
100
+ <div className="relative overflow-hidden" inert={!open || undefined}>
101
+ {render ? children : null}
102
+ </div>
55
103
  </div>
56
104
  )
57
105
  }
58
106
 
59
107
  // CollapseChevron is the disclosure caret that pairs with <Collapse>: a single
60
108
  // ChevronRight that rotates 90° when open, rather than swapping between two
61
- // icons. Matches the drawer Section / ExpandableSection affordance so every
62
- // collapsible surface animates the same way.
63
- export function CollapseChevron({ open, className }: { open: boolean; className?: string }) {
109
+ // icons. Same duration and curve as the panel, so every collapsible surface
110
+ // animates the same way and an interrupted toggle keeps caret and panel in
111
+ // step. Menu triggers are a different thing — they keep ChevronDown/Up
112
+ // flipping toward the menu.
113
+ //
114
+ // The caret is tertiary grey by default. Inside a tinted header (an amber
115
+ // warning, a red error banner, a button whose hover recolors its text) pass
116
+ // `inheritColor` so it takes currentColor from the header instead — a color
117
+ // utility in `className` would race the default in the cascade.
118
+ export function CollapseChevron({
119
+ open,
120
+ className,
121
+ inheritColor = false,
122
+ }: {
123
+ open: boolean
124
+ className?: string
125
+ inheritColor?: boolean
126
+ }) {
64
127
  return (
65
128
  <ChevronRight
66
129
  aria-hidden="true"
67
- className={clsx(
68
- 'shrink-0 text-theme-text-tertiary transition-transform duration-200 motion-reduce:transition-none',
69
- open && 'rotate-90',
70
- className,
71
- )}
130
+ className={clsx('shrink-0', !inheritColor && 'text-theme-text-tertiary', TRANSITION_CHEVRON, open && 'rotate-90', className)}
72
131
  />
73
132
  )
74
133
  }
134
+
135
+ // useDisclosure wires the header ↔ panel relationship: the header gets
136
+ // aria-expanded + aria-controls, the panel gets a stable id. Works with any
137
+ // header markup (a plain button, or a compound row with its own actions), so
138
+ // it does not force a <button> wrapper around things that already contain
139
+ // buttons.
140
+ //
141
+ // const d = useDisclosure(open)
142
+ // <button {...d.buttonProps} onClick={toggle}>…<CollapseChevron open={open} /></button>
143
+ // <Collapse open={open} id={d.panelId}>…</Collapse>
144
+ // Rows mapped inline can't each call useDisclosure; they build ids from one
145
+ // useId() prefix plus a row key. The key is display data (a category name, a
146
+ // cluster id) and may contain whitespace, which would split `aria-controls`
147
+ // into two references — so it is percent-encoded here (injective, so two
148
+ // distinct keys can't land on one id).
149
+ export function disclosurePanelId(base: string, key: string | number): string {
150
+ return `${base}-${encodeURIComponent(String(key))}`
151
+ }
152
+
153
+ export function useDisclosure(open: boolean, explicitId?: string) {
154
+ const generated = useId()
155
+ const panelId = explicitId ?? `disclosure-${generated}`
156
+ return {
157
+ panelId,
158
+ buttonProps: { 'aria-expanded': open, 'aria-controls': panelId } as const,
159
+ }
160
+ }
@@ -2,7 +2,7 @@ import { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type React
2
2
  import { createPortal } from 'react-dom'
3
3
  import { clsx } from 'clsx'
4
4
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
5
- import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
5
+ import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
6
6
 
7
7
  interface DialogPortalProps {
8
8
  open: boolean
@@ -27,7 +27,10 @@ interface DialogPortalProps {
27
27
  */
28
28
  export function DialogPortal({ open, onClose, children, className, closable = true }: DialogPortalProps) {
29
29
  const dialogRef = useRef<HTMLDivElement>(null)
30
- const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
30
+ // The unmount window is the exit duration: shorter than the entrance, and
31
+ // exactly what the panel transition below runs — a mismatch here cut the
32
+ // close at two-thirds (200ms window under a 300ms panel).
33
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('dialog'))
31
34
 
32
35
  // Bubble phase lets nested editors and menus consume Escape before the dialog closes.
33
36
  const handleDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
@@ -81,6 +84,7 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
81
84
  TRANSITION_BACKDROP,
82
85
  isOpen ? 'opacity-100' : 'opacity-0',
83
86
  )}
87
+ style={overlayTransitionStyle(isOpen, 'dialog')}
84
88
  onClick={closable ? onClose : undefined}
85
89
  />
86
90
  <div
@@ -95,6 +99,7 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
95
99
  isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
96
100
  className,
97
101
  )}
102
+ style={overlayTransitionStyle(isOpen, 'dialog')}
98
103
  >
99
104
  {children}
100
105
  </div>
@@ -1,5 +1,6 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { AlertTriangle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
2
+ import { AlertTriangle, Loader2 } from 'lucide-react'
3
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
3
4
  import { ConfirmDialog } from './ConfirmDialog'
4
5
  import { pluralize } from '../../utils/pluralize'
5
6
 
@@ -97,6 +98,7 @@ const MAX_NAMES_PER_KIND = 8
97
98
 
98
99
  function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[] }) {
99
100
  const [expanded, setExpanded] = useState(false)
101
+ const { panelId, buttonProps } = useDisclosure(expanded)
100
102
 
101
103
  const grouped = useMemo(() => {
102
104
  const map = new Map<string, string[]>()
@@ -111,17 +113,18 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
111
113
  return (
112
114
  <div className="rounded border border-amber-500/30 bg-amber-500/5">
113
115
  <button
116
+ {...buttonProps}
114
117
  type="button"
115
118
  onClick={() => setExpanded(!expanded)}
116
119
  className="flex items-center gap-2 w-full px-3 py-2 text-left text-xs font-medium text-amber-400 hover:bg-amber-500/10 transition-colors"
117
120
  >
118
- {expanded ? <ChevronDown className="w-3.5 h-3.5 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 shrink-0" />}
121
+ <CollapseChevron open={expanded} inheritColor className="w-3.5 h-3.5" />
119
122
  <span>
120
123
  Will also delete {pluralize(dependents.length, 'dependent resource')}
121
124
  </span>
122
125
  </button>
123
126
 
124
- {expanded && (
127
+ <Collapse open={expanded} id={panelId}>
125
128
  <div className="px-3 pb-2.5 space-y-1.5">
126
129
  {grouped.map(([kind, names]) => (
127
130
  <div key={kind} className="text-xs">
@@ -136,7 +139,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
136
139
  </div>
137
140
  ))}
138
141
  </div>
139
- )}
142
+ </Collapse>
140
143
  </div>
141
144
  )
142
145
  }
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { clsx } from 'clsx'
3
- import { Shield, ChevronDown, Copy, Check } from 'lucide-react'
3
+ import { Shield, Copy, Check } from 'lucide-react'
4
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
4
5
 
5
6
  // RestrictedState is the shared "you can't see this because of Kubernetes RBAC"
6
7
  // surface — distinct from EmptyState (which covers healthy / filtered / no-data).
@@ -61,6 +62,7 @@ function buildRbacRequest(group: string, resource: string): string {
61
62
 
62
63
  export function RestrictedState({ kindLabel, group = '', resource, reason, compact, className }: Props) {
63
64
  const [expanded, setExpanded] = useState(false)
65
+ const accessDisclosure = useDisclosure(expanded)
64
66
  const [copied, setCopied] = useState(false)
65
67
  const isUnavailable = reason === 'unavailable'
66
68
 
@@ -112,14 +114,15 @@ export function RestrictedState({ kindLabel, group = '', resource, reason, compa
112
114
 
113
115
  <div className="mt-3 w-full max-w-md">
114
116
  <button
117
+ {...accessDisclosure.buttonProps}
115
118
  onClick={() => setExpanded((v) => !v)}
116
119
  className="flex items-center gap-1.5 mx-auto text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors"
117
120
  >
118
- <ChevronDown className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-180')} />
121
+ <CollapseChevron open={expanded} className="w-4 h-4" />
119
122
  How to get access
120
123
  </button>
121
124
 
122
- {expanded && (
125
+ <Collapse open={expanded} id={accessDisclosure.panelId}>
123
126
  <div className="mt-2 text-left">
124
127
  <p className="text-xs text-theme-text-tertiary mb-2">
125
128
  Apply this, or send it to whoever administers your cluster, to grant your identity
@@ -143,7 +146,7 @@ export function RestrictedState({ kindLabel, group = '', resource, reason, compa
143
146
  </pre>
144
147
  </div>
145
148
  </div>
146
- )}
149
+ </Collapse>
147
150
  </div>
148
151
  </>
149
152
  )}
@@ -2,6 +2,8 @@ import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ComponentT
2
2
  import { Loader2, MoreVertical } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Tooltip } from './Tooltip'
5
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
5
7
 
6
8
  export interface RowActionItem {
7
9
  key: string
@@ -25,20 +27,21 @@ interface RowActionMenuProps {
25
27
 
26
28
  export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true }: RowActionMenuProps) {
27
29
  const [open, setOpen] = useState(false)
30
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
28
31
  // Flip the menu above the trigger when it would otherwise spill past the
29
32
  // viewport bottom. The GitOps table's bottom rows sit at the end of a scroll
30
33
  // container with the app's fixed overlay buttons below them, so a
31
34
  // downward-opening menu there clips its lowest items with no way to scroll
32
- // them into view. Measured after open (useLayoutEffect, pre-paint, no flicker).
35
+ // them into view. Measured once the menu is mounted (useLayoutEffect,
36
+ // pre-paint, no flicker). The last placement is kept through the exit
37
+ // transition — resetting at logical close would snap the menu to the other
38
+ // side mid-fade — and re-measured on the next open.
33
39
  const [openUp, setOpenUp] = useState(false)
34
40
  const ref = useRef<HTMLDivElement>(null)
35
41
  const menuRef = useRef<HTMLDivElement>(null)
36
42
 
37
43
  useLayoutEffect(() => {
38
- if (!open) {
39
- setOpenUp(false)
40
- return
41
- }
44
+ if (!open || !shouldRender) return
42
45
  const trigger = ref.current?.getBoundingClientRect()
43
46
  const menuH = menuRef.current?.offsetHeight ?? 0
44
47
  if (!trigger) return
@@ -46,7 +49,7 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
46
49
  // Flip up only when there's not enough room below AND enough room above,
47
50
  // so a tall menu near the top doesn't get clipped at the other end.
48
51
  setOpenUp(menuH + 8 > spaceBelow && trigger.top > menuH + 8)
49
- }, [open])
52
+ }, [open, shouldRender])
50
53
 
51
54
  useEffect(() => {
52
55
  if (!open) return
@@ -89,14 +92,23 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
89
92
  >
90
93
  <MoreVertical className={iconSize} />
91
94
  </button>
92
- {open && (
95
+ {shouldRender && (
93
96
  <div
94
97
  ref={menuRef}
95
98
  role="menu"
99
+ inert={!open}
96
100
  className={clsx(
97
101
  'absolute right-0 z-50 min-w-[180px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
98
- openUp ? 'bottom-full mb-1' : 'top-full mt-1',
102
+ openUp ? 'bottom-full mb-1 origin-bottom-right' : 'top-full mt-1 origin-top-right',
103
+ TRANSITION_MENU,
104
+ isOpen
105
+ ? 'opacity-100 translate-y-0 scale-100'
106
+ : openUp
107
+ ? 'opacity-0 translate-y-1 scale-[0.97]'
108
+ : 'opacity-0 -translate-y-1 scale-[0.97]',
109
+ !open && 'pointer-events-none',
99
110
  )}
111
+ style={overlayTransitionStyle(isOpen, 'menu')}
100
112
  onClick={(e) => e.stopPropagation()}
101
113
  >
102
114
  {items.map((item) => {
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
2
  import { Check, ChevronDown, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
5
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
4
6
 
5
7
  export interface SelectMenuOption {
6
8
  value: string
@@ -29,6 +31,7 @@ export function SelectMenu({
29
31
  id?: string
30
32
  }) {
31
33
  const [open, setOpen] = useState(false)
34
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
32
35
  const [query, setQuery] = useState('')
33
36
  const [highlightedIndex, setHighlightedIndex] = useState(0)
34
37
  const listboxId = useId()
@@ -62,17 +65,21 @@ export function SelectMenu({
62
65
  }
63
66
  }, [open])
64
67
 
68
+ // Reset once the list has actually left the DOM — clearing at logical close
69
+ // would re-filter the options mid-fade.
65
70
  useEffect(() => {
66
- if (!open) setQuery('')
67
- }, [open])
71
+ if (!shouldRender) setQuery('')
72
+ }, [shouldRender])
68
73
 
69
74
  useEffect(() => {
70
75
  if (!open || !query) return
71
76
  if (listRef.current) listRef.current.scrollTop = 0
72
77
  }, [open, query])
73
78
 
79
+ // The list mounts a commit after `open` (presence hook), so this waits for
80
+ // `shouldRender` too — on `open` alone listRef is still null.
74
81
  useEffect(() => {
75
- if (!open) return
82
+ if (!open || !shouldRender) return
76
83
  if (!searchPlaceholder) {
77
84
  triggerRef.current?.focus()
78
85
  return
@@ -80,7 +87,7 @@ export function SelectMenu({
80
87
  listRef.current
81
88
  ?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]')
82
89
  ?.scrollIntoView({ block: 'nearest' })
83
- }, [open, searchPlaceholder])
90
+ }, [open, shouldRender, searchPlaceholder])
84
91
 
85
92
  return (
86
93
  <div
@@ -139,12 +146,17 @@ export function SelectMenu({
139
146
  <span className="truncate">{selected?.label}</span>
140
147
  <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
141
148
  </button>
142
- {open && (
149
+ {shouldRender && (
143
150
  <div
151
+ inert={!open}
144
152
  className={clsx(
145
153
  'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
146
- searchPlaceholder ? 'left-0 right-0' : 'right-0'
154
+ searchPlaceholder ? 'left-0 right-0 origin-top-left' : 'right-0 origin-top-right',
155
+ TRANSITION_MENU,
156
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
157
+ !open && 'pointer-events-none',
147
158
  )}
159
+ style={overlayTransitionStyle(isOpen, 'menu')}
148
160
  >
149
161
  {searchPlaceholder && (
150
162
  <div
@@ -7,7 +7,8 @@ import Editor, {
7
7
  type OnMount,
8
8
  } from '@monaco-editor/react'
9
9
  import type { editor } from 'monaco-editor'
10
- import { AlertCircle, AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react'
10
+ import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
11
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
11
12
  import { parseAllDocuments, parseDocument } from 'yaml'
12
13
  import { isMac } from '../../utils/platform'
13
14
  import { splitYamlDocuments } from '../../utils/yaml'
@@ -228,6 +229,7 @@ export function YamlEditor({
228
229
  const [runtimeAttempt, setRuntimeAttempt] = useState(0)
229
230
  const [diagnostics, setDiagnostics] = useState<YamlDiagnostic[]>([])
230
231
  const [problemsOpen, setProblemsOpen] = useState(false)
232
+ const problemsDisclosure = useDisclosure(problemsOpen)
231
233
  const [schemaStatus, setSchemaStatus] = useState<SchemaStatus>(schemaLoader ? 'loading' : 'idle')
232
234
  const [schemaMessage, setSchemaMessage] = useState('')
233
235
  const [schemaUnavailable, setSchemaUnavailable] = useState<
@@ -613,16 +615,12 @@ export function YamlEditor({
613
615
  {showProblems && (
614
616
  <div className="shrink-0 border-t border-theme-border bg-theme-elevated/60">
615
617
  <button
618
+ {...problemsDisclosure.buttonProps}
616
619
  type="button"
617
620
  onClick={() => setProblemsOpen((open) => !open)}
618
621
  className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
619
- aria-expanded={problemsOpen}
620
622
  >
621
- {problemsOpen ? (
622
- <ChevronDown className="h-3.5 w-3.5" />
623
- ) : (
624
- <ChevronRight className="h-3.5 w-3.5" />
625
- )}
623
+ <CollapseChevron open={problemsOpen} className="h-3.5 w-3.5" />
626
624
  <span className="font-medium text-theme-text-primary">Problems</span>
627
625
  <span>{problemSummary}</span>
628
626
  <span className="ml-auto flex min-w-0 items-center gap-3 text-theme-text-tertiary">
@@ -646,7 +644,7 @@ export function YamlEditor({
646
644
  )}
647
645
  </span>
648
646
  </button>
649
- {problemsOpen && (
647
+ <Collapse open={problemsOpen} id={problemsDisclosure.panelId}>
650
648
  <div className="max-h-36 overflow-auto border-t border-theme-border py-1">
651
649
  {diagnostics.length === 0 && schemaUnavailable.length === 0 ? (
652
650
  <div className="px-3 py-2 text-xs text-theme-text-tertiary">
@@ -699,7 +697,7 @@ export function YamlEditor({
699
697
  )
700
698
  })}
701
699
  </div>
702
- )}
700
+ </Collapse>
703
701
  </div>
704
702
  )}
705
703
  </div>