@skyhook-io/k8s-ui 1.14.6 → 1.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationDetail.tsx +36 -4
  3. package/src/components/applications/ApplicationsView.tsx +2 -1
  4. package/src/components/audit/AuditAlerts.tsx +35 -37
  5. package/src/components/audit/AuditFindingsTable.tsx +23 -27
  6. package/src/components/charts/PrometheusChartsView.tsx +29 -10
  7. package/src/components/checks/ChecksView.tsx +56 -26
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +12 -9
  9. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +92 -0
  10. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +36 -6
  11. package/src/components/issues/IssuesView.tsx +35 -54
  12. package/src/components/logs/LogCore.tsx +40 -9
  13. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  14. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  15. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  16. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  17. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  18. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  19. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  20. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  21. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  22. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  23. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +13 -6
  25. package/src/components/shared/CreateResourceDialog.tsx +7 -4
  26. package/src/components/timeline/TimelineList.tsx +14 -11
  27. package/src/components/timeline/TimelineStrip.tsx +26 -4
  28. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  29. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  30. package/src/components/topology/K8sResourceNode.tsx +4 -2
  31. package/src/components/topology/TopologyControls.tsx +27 -8
  32. package/src/components/topology/TopologyGraph.tsx +14 -5
  33. package/src/components/trace/ReachabilityView.tsx +17 -16
  34. package/src/components/ui/CardSection.tsx +79 -35
  35. package/src/components/ui/Collapse.test.tsx +61 -0
  36. package/src/components/ui/Collapse.tsx +115 -29
  37. package/src/components/ui/DialogPortal.tsx +7 -2
  38. package/src/components/ui/Disclosure.test.tsx +26 -0
  39. package/src/components/ui/Disclosure.tsx +69 -0
  40. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  41. package/src/components/ui/RestrictedState.tsx +7 -4
  42. package/src/components/ui/RowActionMenu.tsx +20 -8
  43. package/src/components/ui/SelectMenu.tsx +18 -6
  44. package/src/components/ui/YamlEditor.tsx +7 -9
  45. package/src/components/ui/drawer-components.tsx +14 -21
  46. package/src/components/ui/index.ts +31 -22
  47. package/src/components/workload/WorkloadView.tsx +6 -10
  48. package/src/hooks/useAnimatedUnmount.ts +21 -3
  49. package/src/theme/components.css +0 -19
  50. package/src/utils/animation.ts +93 -13
@@ -1,4 +1,4 @@
1
- import { Fragment, type ComponentType, type ReactNode } from 'react'
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 = 'neutral' | 'warn' | 'fix'
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: 'text-theme-text-tertiary',
20
+ neutral: "text-theme-text-tertiary",
21
21
  // What's wrong — amber, the diagnosis beat.
22
- warn: 'text-amber-700 dark:text-amber-300',
22
+ warn: "text-amber-700 dark:text-amber-300",
23
23
  // Next step / How to fix — emerald, the action beat.
24
- fix: 'text-emerald-700 dark:text-emerald-400',
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 = 'neutral',
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 className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`} aria-hidden />
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 className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}>
55
+ <h4
56
+ className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}
57
+ >
53
58
  {label}
54
- {labelExtra ? <span className="font-medium normal-case tracking-normal text-theme-text-tertiary"> {labelExtra}</span> : null}
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({ children, className }: { children: ReactNode; className?: string }) {
64
- return <p className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ''}`}>{children}</p>
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 TerminalBlock({ label, children }: { label?: ReactNode; children: ReactNode }) {
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
- 'inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-secondary'
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
- 'shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary'
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('`')) return text
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('`') && part.endsWith('`')) {
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 (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>
@@ -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, 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
  }