@skyhook-io/k8s-ui 1.14.6 → 1.14.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/gitops/tree/GitOpsTreeGraph.test.ts +92 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +36 -6
- 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/K8sResourceNode.tsx +4 -2
- 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 +61 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/Disclosure.test.tsx +26 -0
- package/src/components/ui/Disclosure.tsx +69 -0
- 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 +31 -22
- package/src/components/workload/WorkloadView.tsx +6 -10
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +0 -19
- package/src/utils/animation.ts +93 -13
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, type ComponentType, type ReactNode } from
|
|
1
|
+
import { Fragment, type ComponentType, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
// =============================================================================
|
|
4
4
|
// CARD SECTION PRIMITIVES — the shared "Sectioned + icons" vocabulary used by
|
|
@@ -12,106 +12,150 @@ import { Fragment, type ComponentType, type ReactNode } from 'react'
|
|
|
12
12
|
|
|
13
13
|
// Section role → eyebrow + icon hue. Body text always uses the secondary token
|
|
14
14
|
// (the contrast floor), regardless of tone.
|
|
15
|
-
export type CardSectionTone =
|
|
15
|
+
export type CardSectionTone = "neutral" | "warn" | "fix";
|
|
16
16
|
|
|
17
17
|
const SECTION_TONE_CLASS: Record<CardSectionTone, string> = {
|
|
18
18
|
// Eyebrow grey — the default, for neutral sections (Why it matters, Raw error,
|
|
19
19
|
// Affected resources, Context, Subject).
|
|
20
|
-
neutral:
|
|
20
|
+
neutral: "text-theme-text-tertiary",
|
|
21
21
|
// What's wrong — amber, the diagnosis beat.
|
|
22
|
-
warn:
|
|
22
|
+
warn: "text-amber-700 dark:text-amber-300",
|
|
23
23
|
// Next step / How to fix — emerald, the action beat.
|
|
24
|
-
fix:
|
|
25
|
-
}
|
|
24
|
+
fix: "text-emerald-700 dark:text-emerald-400",
|
|
25
|
+
};
|
|
26
26
|
|
|
27
27
|
// The single eyebrow style: 11px / 600 / uppercase, with an icon in a fixed
|
|
28
28
|
// gutter so every section's body aligns under the same left edge.
|
|
29
29
|
export function CardSection({
|
|
30
30
|
icon: Icon,
|
|
31
31
|
label,
|
|
32
|
-
tone =
|
|
32
|
+
tone = "neutral",
|
|
33
33
|
labelExtra,
|
|
34
34
|
children,
|
|
35
35
|
}: {
|
|
36
|
-
icon: ComponentType<{ className?: string }
|
|
37
|
-
label: string
|
|
38
|
-
tone?: CardSectionTone
|
|
36
|
+
icon: ComponentType<{ className?: string }>;
|
|
37
|
+
label: string;
|
|
38
|
+
tone?: CardSectionTone;
|
|
39
39
|
/** Optional trailing eyebrow text (e.g. "· retried 5×"), rendered muted. */
|
|
40
|
-
labelExtra?: ReactNode
|
|
41
|
-
children: ReactNode
|
|
40
|
+
labelExtra?: ReactNode;
|
|
41
|
+
children: ReactNode;
|
|
42
42
|
}) {
|
|
43
|
-
const toneClass = SECTION_TONE_CLASS[tone]
|
|
43
|
+
const toneClass = SECTION_TONE_CLASS[tone];
|
|
44
44
|
return (
|
|
45
45
|
<section className="flex items-start gap-2.5">
|
|
46
46
|
{/* Icon sits in a fixed gutter, anchored to the eyebrow line (top-aligned
|
|
47
47
|
with a nudge to its optical center) — sections like Affected resources
|
|
48
48
|
can be dozens of rows tall, and a section-centered glyph would float
|
|
49
49
|
detached mid-list. */}
|
|
50
|
-
<Icon
|
|
50
|
+
<Icon
|
|
51
|
+
className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`}
|
|
52
|
+
aria-hidden
|
|
53
|
+
/>
|
|
51
54
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
52
|
-
<h4
|
|
55
|
+
<h4
|
|
56
|
+
className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}
|
|
57
|
+
>
|
|
53
58
|
{label}
|
|
54
|
-
{labelExtra ?
|
|
59
|
+
{labelExtra ? (
|
|
60
|
+
<span className="font-medium normal-case tracking-normal text-theme-text-tertiary">
|
|
61
|
+
{" "}
|
|
62
|
+
{labelExtra}
|
|
63
|
+
</span>
|
|
64
|
+
) : null}
|
|
55
65
|
</h4>
|
|
56
66
|
{children}
|
|
57
67
|
</div>
|
|
58
68
|
</section>
|
|
59
|
-
)
|
|
69
|
+
);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
// Body paragraph at the contrast floor — 14px, secondary token, never lighter.
|
|
63
|
-
export function CardBody({
|
|
64
|
-
|
|
73
|
+
export function CardBody({
|
|
74
|
+
children,
|
|
75
|
+
className,
|
|
76
|
+
}: {
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
className?: string;
|
|
79
|
+
}) {
|
|
80
|
+
return (
|
|
81
|
+
<p
|
|
82
|
+
className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ""}`}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</p>
|
|
86
|
+
);
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
// The dark terminal block for raw error output. Intentionally dark in BOTH
|
|
68
90
|
// themes (it's a console surface, not chrome), so it draws from the dedicated
|
|
69
91
|
// --terminal-* tokens rather than the theme surface tokens.
|
|
70
|
-
export function
|
|
92
|
+
export function TerminalBlockLabel({
|
|
93
|
+
children,
|
|
94
|
+
divider,
|
|
95
|
+
}: {
|
|
96
|
+
children: ReactNode;
|
|
97
|
+
divider?: boolean;
|
|
98
|
+
}) {
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={`${divider ? "border-t" : "border-b"} border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]`}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function TerminalBlock({
|
|
109
|
+
label,
|
|
110
|
+
children,
|
|
111
|
+
footer,
|
|
112
|
+
}: {
|
|
113
|
+
label?: ReactNode;
|
|
114
|
+
children: ReactNode;
|
|
115
|
+
/** Rendered under the output, inside the same block: a folded tail, a divider. */
|
|
116
|
+
footer?: ReactNode;
|
|
117
|
+
}) {
|
|
71
118
|
return (
|
|
72
119
|
<div className="overflow-hidden rounded-lg border border-[var(--terminal-border)] bg-[var(--terminal-bg)]">
|
|
73
|
-
{label ?
|
|
74
|
-
<div className="border-b border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]">
|
|
75
|
-
{label}
|
|
76
|
-
</div>
|
|
77
|
-
) : null}
|
|
120
|
+
{label ? <TerminalBlockLabel>{label}</TerminalBlockLabel> : null}
|
|
78
121
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
|
|
79
122
|
{children}
|
|
80
123
|
</pre>
|
|
124
|
+
{footer}
|
|
81
125
|
</div>
|
|
82
|
-
)
|
|
126
|
+
);
|
|
83
127
|
}
|
|
84
128
|
|
|
85
129
|
// The neutral category chip — drops the per-category hue so severity is the only
|
|
86
130
|
// status color. Self-contained (don't pair with badge-sm): 11px / 600, 2px×8px
|
|
87
131
|
// padding, no border, neutral grey.
|
|
88
132
|
export const NEUTRAL_CHIP_CLASS =
|
|
89
|
-
|
|
133
|
+
"inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-secondary";
|
|
90
134
|
|
|
91
135
|
// Mono kind chip — the grey tag for an all-caps resource Kind (the Issue row
|
|
92
136
|
// header + the Subject footer row). Mono and tighter than NEUTRAL_CHIP_CLASS,
|
|
93
137
|
// which carries the label/category chips.
|
|
94
138
|
export const KIND_CHIP_CLASS =
|
|
95
|
-
|
|
139
|
+
"shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary";
|
|
96
140
|
|
|
97
141
|
// Render a prose string, turning markdown `inline-code` spans into mono chips
|
|
98
142
|
// via the shared .inline-code class. Plain-text passthrough when the string
|
|
99
143
|
// carries no backticks, so it's a no-op for today's plain catalog copy and
|
|
100
144
|
// lights up automatically once backtick markup is added.
|
|
101
145
|
export function renderProse(text: string | undefined | null): ReactNode {
|
|
102
|
-
if (!text) return null
|
|
103
|
-
if (!text.includes(
|
|
104
|
-
const parts = text.split(/(`[^`]+`)/g)
|
|
146
|
+
if (!text) return null;
|
|
147
|
+
if (!text.includes("`")) return text;
|
|
148
|
+
const parts = text.split(/(`[^`]+`)/g);
|
|
105
149
|
return parts.map((part, i) => {
|
|
106
150
|
// length > 2 excludes a bare "``" (which the split leaves in a plain part)
|
|
107
151
|
// so it renders as literal text, not an empty chip.
|
|
108
|
-
if (part.length > 2 && part.startsWith(
|
|
152
|
+
if (part.length > 2 && part.startsWith("`") && part.endsWith("`")) {
|
|
109
153
|
return (
|
|
110
154
|
<code key={i} className="inline-code">
|
|
111
155
|
{part.slice(1, -1)}
|
|
112
156
|
</code>
|
|
113
|
-
)
|
|
157
|
+
);
|
|
114
158
|
}
|
|
115
|
-
return <Fragment key={i}>{part}</Fragment
|
|
116
|
-
})
|
|
159
|
+
return <Fragment key={i}>{part}</Fragment>;
|
|
160
|
+
});
|
|
117
161
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { Collapse, CollapseChevron, disclosurePanelId } from './Collapse'
|
|
4
|
+
import {
|
|
5
|
+
DURATION_DISCLOSURE,
|
|
6
|
+
TRANSITION_CHEVRON,
|
|
7
|
+
TRANSITION_DISCLOSURE,
|
|
8
|
+
} from '../../utils/animation'
|
|
9
|
+
|
|
10
|
+
// The primitive is the app-wide standard for expand/collapse; these pin the
|
|
11
|
+
// contract other components rely on rather than the visuals.
|
|
12
|
+
describe('Collapse', () => {
|
|
13
|
+
it('animates grid-template-rows with the shared disclosure timing', () => {
|
|
14
|
+
const html = renderToString(<Collapse open={false}>x</Collapse>)
|
|
15
|
+
expect(html).toContain('grid-template-rows:0fr')
|
|
16
|
+
expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
17
|
+
expect(TRANSITION_DISCLOSURE).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
18
|
+
expect(TRANSITION_DISCLOSURE).toContain('motion-reduce:transition-none')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps closed content mounted but inert, and clipped', () => {
|
|
22
|
+
const html = renderToString(<Collapse open={false}>hidden-content</Collapse>)
|
|
23
|
+
expect(html).toContain('hidden-content')
|
|
24
|
+
expect(html).toMatch(/inert=""|inert>/)
|
|
25
|
+
expect(html).toContain('overflow-hidden')
|
|
26
|
+
const opened = renderToString(<Collapse open>shown</Collapse>)
|
|
27
|
+
expect(opened).toContain('grid-template-rows:1fr')
|
|
28
|
+
expect(opened).not.toMatch(/inert=""|inert>/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('mountLazily renders nothing until first open', () => {
|
|
32
|
+
expect(renderToString(<Collapse open={false} mountLazily>lazy</Collapse>)).not.toContain('lazy')
|
|
33
|
+
expect(renderToString(<Collapse open mountLazily>lazy</Collapse>)).toContain('lazy')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('unmountOnExit renders nothing when initially closed', () => {
|
|
37
|
+
// Initial-closed mount never had a transition to wait for.
|
|
38
|
+
expect(renderToString(<Collapse open={false} unmountOnExit>gone</Collapse>)).not.toContain('gone')
|
|
39
|
+
expect(renderToString(<Collapse open unmountOnExit>here</Collapse>)).toContain('here')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('exposes the panel id for aria-controls', () => {
|
|
43
|
+
expect(renderToString(<Collapse open id="panel-1">x</Collapse>)).toContain('id="panel-1"')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('disclosurePanelId never yields whitespace (aria-controls is a space-separated list)', () => {
|
|
47
|
+
expect(disclosurePanelId(':r1:', 'Access Control')).toBe(':r1:-Access%20Control')
|
|
48
|
+
expect(disclosurePanelId(':r1:', 7)).toBe(':r1:-7')
|
|
49
|
+
// Injective: keys that only differ by the separator stay distinct.
|
|
50
|
+
expect(disclosurePanelId('p', 'a b')).not.toBe(disclosurePanelId('p', 'a_b'))
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('CollapseChevron', () => {
|
|
55
|
+
it('runs the same clock as the panel', () => {
|
|
56
|
+
expect(TRANSITION_CHEVRON).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
57
|
+
const html = renderToString(<CollapseChevron open />)
|
|
58
|
+
expect(html).toContain('rotate-90')
|
|
59
|
+
expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -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>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { Disclosure } from './Disclosure'
|
|
4
|
+
|
|
5
|
+
describe('Disclosure', () => {
|
|
6
|
+
it('wires the header to the panel and keeps closed content mounted', () => {
|
|
7
|
+
const html = renderToString(<Disclosure summary="More">hidden-copy</Disclosure>)
|
|
8
|
+
expect(html).toContain('aria-expanded="false"')
|
|
9
|
+
const id = html.match(/aria-controls="([^"]+)"/)?.[1]
|
|
10
|
+
expect(id).toBeTruthy()
|
|
11
|
+
expect(html).toContain(`id="${id}"`)
|
|
12
|
+
expect(html).toContain('hidden-copy')
|
|
13
|
+
expect(html).toMatch(/inert=""|inert>/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('honours defaultOpen and a controlled open', () => {
|
|
17
|
+
expect(renderToString(<Disclosure summary="s" defaultOpen>x</Disclosure>)).toContain('aria-expanded="true"')
|
|
18
|
+
expect(renderToString(<Disclosure summary="s" open onOpenChange={() => {}}>x</Disclosure>)).toContain('aria-expanded="true"')
|
|
19
|
+
expect(renderToString(<Disclosure summary="s" open={false} defaultOpen>x</Disclosure>)).toContain('aria-expanded="false"')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('caret follows the header color unless pinned', () => {
|
|
23
|
+
expect(renderToString(<Disclosure summary="s">x</Disclosure>)).not.toContain('text-theme-text-tertiary')
|
|
24
|
+
expect(renderToString(<Disclosure summary="s" inheritColor={false}>x</Disclosure>)).toContain('text-theme-text-tertiary')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
|
|
4
|
+
|
|
5
|
+
// Disclosure — in-flow expand/collapse with the app-standard motion; the
|
|
6
|
+
// replacement for native <details>/<summary> on primary paths. Native details
|
|
7
|
+
// snaps open with no transition and its marker ignores the caret language;
|
|
8
|
+
// this pairs a button header (aria-expanded / aria-controls via useDisclosure)
|
|
9
|
+
// with CollapseChevron and an animated Collapse panel. Shared by Radar and
|
|
10
|
+
// Radar Cloud so both open alike.
|
|
11
|
+
//
|
|
12
|
+
// Content stays mounted while closed (Collapse's default), exactly like native
|
|
13
|
+
// details — server-rendered markup and in-page search still see the collapsed
|
|
14
|
+
// copy. Sites whose panel polls or holds an editor should use
|
|
15
|
+
// <Collapse unmountOnExit> directly instead.
|
|
16
|
+
//
|
|
17
|
+
// Uncontrolled by default (`defaultOpen`); pass `open` + `onOpenChange` to
|
|
18
|
+
// control it from above (a form that keys other state on the disclosure).
|
|
19
|
+
// The chevron takes currentColor from the header by default: a summary's
|
|
20
|
+
// caret should read as part of its text, including on hover.
|
|
21
|
+
export function Disclosure({
|
|
22
|
+
summary,
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
summaryClassName,
|
|
26
|
+
chevronClassName = 'h-3.5 w-3.5',
|
|
27
|
+
inheritColor = true,
|
|
28
|
+
defaultOpen = false,
|
|
29
|
+
open: controlledOpen,
|
|
30
|
+
onOpenChange,
|
|
31
|
+
}: {
|
|
32
|
+
summary: ReactNode
|
|
33
|
+
children: ReactNode
|
|
34
|
+
/** Classes for the wrapper around header and panel. */
|
|
35
|
+
className?: string
|
|
36
|
+
/** Classes for the header button (layout, size and color of the summary). */
|
|
37
|
+
summaryClassName?: string
|
|
38
|
+
chevronClassName?: string
|
|
39
|
+
/** False pins the caret to the tertiary grey regardless of the header's color. */
|
|
40
|
+
inheritColor?: boolean
|
|
41
|
+
defaultOpen?: boolean
|
|
42
|
+
open?: boolean
|
|
43
|
+
onOpenChange?: (open: boolean) => void
|
|
44
|
+
}) {
|
|
45
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
|
46
|
+
const open = controlledOpen ?? uncontrolledOpen
|
|
47
|
+
const disclosure = useDisclosure(open)
|
|
48
|
+
const toggle = () => {
|
|
49
|
+
const next = !open
|
|
50
|
+
if (controlledOpen === undefined) setUncontrolledOpen(next)
|
|
51
|
+
onOpenChange?.(next)
|
|
52
|
+
}
|
|
53
|
+
return (
|
|
54
|
+
<div className={className}>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
{...disclosure.buttonProps}
|
|
58
|
+
onClick={toggle}
|
|
59
|
+
className={clsx('flex w-full cursor-pointer select-none items-center gap-1.5 text-left', summaryClassName)}
|
|
60
|
+
>
|
|
61
|
+
<CollapseChevron open={open} inheritColor={inheritColor} className={chevronClassName} />
|
|
62
|
+
{summary}
|
|
63
|
+
</button>
|
|
64
|
+
<Collapse open={open} id={disclosure.panelId}>
|
|
65
|
+
{children}
|
|
66
|
+
</Collapse>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -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
|
}
|