@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.
- package/package.json +1 -1
- package/src/components/applications/ApplicationDetail.tsx +36 -4
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/audit/AuditAlerts.tsx +35 -37
- package/src/components/audit/AuditFindingsTable.tsx +23 -27
- package/src/components/charts/PrometheusChartsView.tsx +29 -10
- package/src/components/checks/ChecksView.tsx +56 -26
- package/src/components/gitops/GitOpsDetailLayout.tsx +12 -9
- package/src/components/issues/IssuesView.tsx +35 -54
- package/src/components/logs/LogCore.tsx +40 -9
- package/src/components/resources/ResourcesSidebar.tsx +66 -78
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
- package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
- package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +13 -6
- package/src/components/shared/CreateResourceDialog.tsx +7 -4
- package/src/components/timeline/TimelineList.tsx +14 -11
- package/src/components/timeline/TimelineStrip.tsx +26 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
- package/src/components/timeline/TimelineToolbar.tsx +43 -10
- package/src/components/topology/TopologyControls.tsx +27 -8
- package/src/components/topology/TopologyGraph.tsx +14 -5
- package/src/components/trace/ReachabilityView.tsx +17 -16
- package/src/components/ui/CardSection.tsx +79 -35
- package/src/components/ui/Collapse.test.tsx +74 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
- package/src/components/ui/RestrictedState.tsx +7 -4
- package/src/components/ui/RowActionMenu.tsx +20 -8
- package/src/components/ui/SelectMenu.tsx +18 -6
- package/src/components/ui/YamlEditor.tsx +7 -9
- package/src/components/ui/drawer-components.tsx +14 -21
- package/src/components/ui/index.ts +30 -22
- package/src/components/workload/WorkloadView.tsx +6 -10
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +4 -1
- 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 (
|
|
7
|
-
// sidebar,
|
|
8
|
-
// it clips the content while
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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}>
|
|
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.
|
|
62
|
-
//
|
|
63
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
<
|
|
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
|
|
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
|
-
{
|
|
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 (!
|
|
67
|
-
}, [
|
|
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
|
-
{
|
|
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,
|
|
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>
|