@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,6 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { clsx } from 'clsx'
3
- import { Shield, ChevronDown, Copy, Check } from 'lucide-react'
3
+ import { Shield, Copy, Check } from 'lucide-react'
4
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
4
5
 
5
6
  // RestrictedState is the shared "you can't see this because of Kubernetes RBAC"
6
7
  // surface — distinct from EmptyState (which covers healthy / filtered / no-data).
@@ -61,6 +62,7 @@ function buildRbacRequest(group: string, resource: string): string {
61
62
 
62
63
  export function RestrictedState({ kindLabel, group = '', resource, reason, compact, className }: Props) {
63
64
  const [expanded, setExpanded] = useState(false)
65
+ const accessDisclosure = useDisclosure(expanded)
64
66
  const [copied, setCopied] = useState(false)
65
67
  const isUnavailable = reason === 'unavailable'
66
68
 
@@ -112,14 +114,15 @@ export function RestrictedState({ kindLabel, group = '', resource, reason, compa
112
114
 
113
115
  <div className="mt-3 w-full max-w-md">
114
116
  <button
117
+ {...accessDisclosure.buttonProps}
115
118
  onClick={() => setExpanded((v) => !v)}
116
119
  className="flex items-center gap-1.5 mx-auto text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors"
117
120
  >
118
- <ChevronDown className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-180')} />
121
+ <CollapseChevron open={expanded} className="w-4 h-4" />
119
122
  How to get access
120
123
  </button>
121
124
 
122
- {expanded && (
125
+ <Collapse open={expanded} id={accessDisclosure.panelId}>
123
126
  <div className="mt-2 text-left">
124
127
  <p className="text-xs text-theme-text-tertiary mb-2">
125
128
  Apply this, or send it to whoever administers your cluster, to grant your identity
@@ -143,7 +146,7 @@ export function RestrictedState({ kindLabel, group = '', resource, reason, compa
143
146
  </pre>
144
147
  </div>
145
148
  </div>
146
- )}
149
+ </Collapse>
147
150
  </div>
148
151
  </>
149
152
  )}
@@ -2,6 +2,8 @@ import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ComponentT
2
2
  import { Loader2, MoreVertical } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Tooltip } from './Tooltip'
5
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
5
7
 
6
8
  export interface RowActionItem {
7
9
  key: string
@@ -25,20 +27,21 @@ interface RowActionMenuProps {
25
27
 
26
28
  export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true }: RowActionMenuProps) {
27
29
  const [open, setOpen] = useState(false)
30
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
28
31
  // Flip the menu above the trigger when it would otherwise spill past the
29
32
  // viewport bottom. The GitOps table's bottom rows sit at the end of a scroll
30
33
  // container with the app's fixed overlay buttons below them, so a
31
34
  // downward-opening menu there clips its lowest items with no way to scroll
32
- // them into view. Measured after open (useLayoutEffect, pre-paint, no flicker).
35
+ // them into view. Measured once the menu is mounted (useLayoutEffect,
36
+ // pre-paint, no flicker). The last placement is kept through the exit
37
+ // transition — resetting at logical close would snap the menu to the other
38
+ // side mid-fade — and re-measured on the next open.
33
39
  const [openUp, setOpenUp] = useState(false)
34
40
  const ref = useRef<HTMLDivElement>(null)
35
41
  const menuRef = useRef<HTMLDivElement>(null)
36
42
 
37
43
  useLayoutEffect(() => {
38
- if (!open) {
39
- setOpenUp(false)
40
- return
41
- }
44
+ if (!open || !shouldRender) return
42
45
  const trigger = ref.current?.getBoundingClientRect()
43
46
  const menuH = menuRef.current?.offsetHeight ?? 0
44
47
  if (!trigger) return
@@ -46,7 +49,7 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
46
49
  // Flip up only when there's not enough room below AND enough room above,
47
50
  // so a tall menu near the top doesn't get clipped at the other end.
48
51
  setOpenUp(menuH + 8 > spaceBelow && trigger.top > menuH + 8)
49
- }, [open])
52
+ }, [open, shouldRender])
50
53
 
51
54
  useEffect(() => {
52
55
  if (!open) return
@@ -89,14 +92,23 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
89
92
  >
90
93
  <MoreVertical className={iconSize} />
91
94
  </button>
92
- {open && (
95
+ {shouldRender && (
93
96
  <div
94
97
  ref={menuRef}
95
98
  role="menu"
99
+ inert={!open}
96
100
  className={clsx(
97
101
  'absolute right-0 z-50 min-w-[180px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
98
- openUp ? 'bottom-full mb-1' : 'top-full mt-1',
102
+ openUp ? 'bottom-full mb-1 origin-bottom-right' : 'top-full mt-1 origin-top-right',
103
+ TRANSITION_MENU,
104
+ isOpen
105
+ ? 'opacity-100 translate-y-0 scale-100'
106
+ : openUp
107
+ ? 'opacity-0 translate-y-1 scale-[0.97]'
108
+ : 'opacity-0 -translate-y-1 scale-[0.97]',
109
+ !open && 'pointer-events-none',
99
110
  )}
111
+ style={overlayTransitionStyle(isOpen, 'menu')}
100
112
  onClick={(e) => e.stopPropagation()}
101
113
  >
102
114
  {items.map((item) => {
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
2
  import { Check, ChevronDown, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
5
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
4
6
 
5
7
  export interface SelectMenuOption {
6
8
  value: string
@@ -29,6 +31,7 @@ export function SelectMenu({
29
31
  id?: string
30
32
  }) {
31
33
  const [open, setOpen] = useState(false)
34
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
32
35
  const [query, setQuery] = useState('')
33
36
  const [highlightedIndex, setHighlightedIndex] = useState(0)
34
37
  const listboxId = useId()
@@ -62,17 +65,21 @@ export function SelectMenu({
62
65
  }
63
66
  }, [open])
64
67
 
68
+ // Reset once the list has actually left the DOM — clearing at logical close
69
+ // would re-filter the options mid-fade.
65
70
  useEffect(() => {
66
- if (!open) setQuery('')
67
- }, [open])
71
+ if (!shouldRender) setQuery('')
72
+ }, [shouldRender])
68
73
 
69
74
  useEffect(() => {
70
75
  if (!open || !query) return
71
76
  if (listRef.current) listRef.current.scrollTop = 0
72
77
  }, [open, query])
73
78
 
79
+ // The list mounts a commit after `open` (presence hook), so this waits for
80
+ // `shouldRender` too — on `open` alone listRef is still null.
74
81
  useEffect(() => {
75
- if (!open) return
82
+ if (!open || !shouldRender) return
76
83
  if (!searchPlaceholder) {
77
84
  triggerRef.current?.focus()
78
85
  return
@@ -80,7 +87,7 @@ export function SelectMenu({
80
87
  listRef.current
81
88
  ?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]')
82
89
  ?.scrollIntoView({ block: 'nearest' })
83
- }, [open, searchPlaceholder])
90
+ }, [open, shouldRender, searchPlaceholder])
84
91
 
85
92
  return (
86
93
  <div
@@ -139,12 +146,17 @@ export function SelectMenu({
139
146
  <span className="truncate">{selected?.label}</span>
140
147
  <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
141
148
  </button>
142
- {open && (
149
+ {shouldRender && (
143
150
  <div
151
+ inert={!open}
144
152
  className={clsx(
145
153
  'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
146
- searchPlaceholder ? 'left-0 right-0' : 'right-0'
154
+ searchPlaceholder ? 'left-0 right-0 origin-top-left' : 'right-0 origin-top-right',
155
+ TRANSITION_MENU,
156
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
157
+ !open && 'pointer-events-none',
147
158
  )}
159
+ style={overlayTransitionStyle(isOpen, 'menu')}
148
160
  >
149
161
  {searchPlaceholder && (
150
162
  <div
@@ -7,7 +7,8 @@ import Editor, {
7
7
  type OnMount,
8
8
  } from '@monaco-editor/react'
9
9
  import type { editor } from 'monaco-editor'
10
- import { AlertCircle, AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react'
10
+ import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
11
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
11
12
  import { parseAllDocuments, parseDocument } from 'yaml'
12
13
  import { isMac } from '../../utils/platform'
13
14
  import { splitYamlDocuments } from '../../utils/yaml'
@@ -228,6 +229,7 @@ export function YamlEditor({
228
229
  const [runtimeAttempt, setRuntimeAttempt] = useState(0)
229
230
  const [diagnostics, setDiagnostics] = useState<YamlDiagnostic[]>([])
230
231
  const [problemsOpen, setProblemsOpen] = useState(false)
232
+ const problemsDisclosure = useDisclosure(problemsOpen)
231
233
  const [schemaStatus, setSchemaStatus] = useState<SchemaStatus>(schemaLoader ? 'loading' : 'idle')
232
234
  const [schemaMessage, setSchemaMessage] = useState('')
233
235
  const [schemaUnavailable, setSchemaUnavailable] = useState<
@@ -613,16 +615,12 @@ export function YamlEditor({
613
615
  {showProblems && (
614
616
  <div className="shrink-0 border-t border-theme-border bg-theme-elevated/60">
615
617
  <button
618
+ {...problemsDisclosure.buttonProps}
616
619
  type="button"
617
620
  onClick={() => setProblemsOpen((open) => !open)}
618
621
  className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
619
- aria-expanded={problemsOpen}
620
622
  >
621
- {problemsOpen ? (
622
- <ChevronDown className="h-3.5 w-3.5" />
623
- ) : (
624
- <ChevronRight className="h-3.5 w-3.5" />
625
- )}
623
+ <CollapseChevron open={problemsOpen} className="h-3.5 w-3.5" />
626
624
  <span className="font-medium text-theme-text-primary">Problems</span>
627
625
  <span>{problemSummary}</span>
628
626
  <span className="ml-auto flex min-w-0 items-center gap-3 text-theme-text-tertiary">
@@ -646,7 +644,7 @@ export function YamlEditor({
646
644
  )}
647
645
  </span>
648
646
  </button>
649
- {problemsOpen && (
647
+ <Collapse open={problemsOpen} id={problemsDisclosure.panelId}>
650
648
  <div className="max-h-36 overflow-auto border-t border-theme-border py-1">
651
649
  {diagnostics.length === 0 && schemaUnavailable.length === 0 ? (
652
650
  <div className="px-3 py-2 text-xs text-theme-text-tertiary">
@@ -699,7 +697,7 @@ export function YamlEditor({
699
697
  )
700
698
  })}
701
699
  </div>
702
- )}
700
+ </Collapse>
703
701
  </div>
704
702
  )}
705
703
  </div>
@@ -1,10 +1,11 @@
1
1
  import { createContext, useContext, useState } from 'react'
2
- import { ChevronRight, Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
2
+ import { Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { formatAge, formatDuration, formatResources } from '../resources/resource-utils'
5
5
  import { getEffectiveResources } from '../../utils/extended-resources'
6
6
  import { Tooltip } from './Tooltip'
7
7
  import { getKindColorClass } from '../ui/Badge'
8
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
8
9
 
9
10
  // ============================================================================
10
11
  // UI COMPONENTS
@@ -86,26 +87,22 @@ interface SectionProps {
86
87
 
87
88
  export function Section({ title, icon: Icon, children, defaultExpanded = true, contentClassName }: SectionProps) {
88
89
  const [expanded, setExpanded] = useState(defaultExpanded)
90
+ const { panelId, buttonProps } = useDisclosure(expanded)
89
91
 
90
92
  return (
91
93
  <div className="border-b-subtle pb-4 last:border-0">
92
94
  <button
95
+ {...buttonProps}
93
96
  onClick={() => setExpanded(!expanded)}
94
97
  className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
95
98
  >
96
- <ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
99
+ <CollapseChevron open={expanded} className="w-4 h-4" />
97
100
  {Icon && <Icon className="w-4 h-4 text-theme-text-secondary" />}
98
101
  <span className="text-sm font-medium text-theme-text-secondary">{title}</span>
99
102
  </button>
100
- <div
101
- className="grid transition-[grid-template-rows] duration-200 ease-out"
102
- style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
103
- >
104
- {/* `relative` is load-bearing — see the note in ui/Collapse.tsx. */}
105
- <div className="relative overflow-hidden">
106
- <div className={contentClassName ?? 'pl-6'}>{children}</div>
107
- </div>
108
- </div>
103
+ <Collapse open={expanded} id={panelId}>
104
+ <div className={contentClassName ?? 'pl-6'}>{children}</div>
105
+ </Collapse>
109
106
  </div>
110
107
  )
111
108
  }
@@ -118,25 +115,21 @@ interface ExpandableSectionProps {
118
115
 
119
116
  export function ExpandableSection({ title, children, defaultExpanded = true }: ExpandableSectionProps) {
120
117
  const [expanded, setExpanded] = useState(defaultExpanded)
118
+ const { panelId, buttonProps } = useDisclosure(expanded)
121
119
 
122
120
  return (
123
121
  <div>
124
122
  <button
123
+ {...buttonProps}
125
124
  onClick={() => setExpanded(!expanded)}
126
125
  className="flex items-center gap-2 text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors mb-1"
127
126
  >
128
- <ChevronRight className={clsx('w-3.5 h-3.5 transition-transform duration-200', expanded && 'rotate-90')} />
127
+ <CollapseChevron open={expanded} className="w-3.5 h-3.5" />
129
128
  {title}
130
129
  </button>
131
- <div
132
- className="grid transition-[grid-template-rows] duration-200 ease-out"
133
- style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
134
- >
135
- {/* `relative` is load-bearing — see the note in ui/Collapse.tsx. */}
136
- <div className="relative overflow-hidden">
137
- <div className="ml-5">{children}</div>
138
- </div>
139
- </div>
130
+ <Collapse open={expanded} id={panelId}>
131
+ <div className="ml-5">{children}</div>
132
+ </Collapse>
140
133
  </div>
141
134
  )
142
135
  }
@@ -26,7 +26,8 @@ export { ConfirmDialog } from './ConfirmDialog'
26
26
  export { HealthRing } from './HealthRing'
27
27
  export { MetricsChart, MetricsSparkline } from './MetricsChart'
28
28
  export * from './drawer-components'
29
- export { Collapse, CollapseChevron } from './Collapse'
29
+ export { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from './Collapse'
30
+ export { Disclosure } from './Disclosure'
30
31
  export { ResourceBar } from './ResourceBar'
31
32
  export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
32
33
  export { InClusterConsentDialog } from './InClusterConsentDialog'
@@ -37,31 +38,39 @@ export { YamlEditor, YamlDiffEditor } from './YamlEditor'
37
38
  // pulling monaco-editor into the main bundle. Anything that mounts a bare
38
39
  // `Editor` must await this first, or @monaco-editor/react reaches for its CDN.
39
40
  export const ensureMonacoRuntime = () =>
40
- import('./monacoRuntime').then(({ ensureMonaco }) => ensureMonaco())
41
+ import("./monacoRuntime").then(({ ensureMonaco }) => ensureMonaco());
41
42
  export type {
42
43
  YamlDiagnostic,
43
44
  YamlDocumentIdentity,
44
45
  YamlEditorProps,
45
46
  YamlSchemaLoader,
46
47
  YamlSchemaLoadResult,
47
- } from './YamlEditor'
48
- export { YamlReview } from './YamlReview'
49
- export type { YamlPreviewResult, YamlReviewProps } from './YamlReview'
50
- export { RowActionMenu } from './RowActionMenu'
51
- export type { RowActionItem } from './RowActionMenu'
48
+ } from "./YamlEditor";
49
+ export { YamlReview } from "./YamlReview";
50
+ export type { YamlPreviewResult, YamlReviewProps } from "./YamlReview";
51
+ export { RowActionMenu } from "./RowActionMenu";
52
+ export type { RowActionItem } from "./RowActionMenu";
52
53
 
53
- export { PageHeader } from './PageHeader'
54
- export { SummaryTile } from './SummaryTile'
55
- export type { SummaryTone } from './SummaryTile'
56
- export { Facet, FacetSection, FacetButton } from './Facet'
57
- export type { FacetTone } from './Facet'
58
- export { SortableTh, TH_CLASS } from './SortableTh'
59
- export type { SortDir } from './SortableTh'
60
- export { DistributionBar, DistributionLegendChip } from './DistributionBar'
61
- export type { DistributionSegment } from './DistributionBar'
62
- export { SearchPillInput } from './SearchPillInput'
63
- export type { SearchModifier, SearchPillInputProps } from './SearchPillInput'
64
- export { CardSection, CardBody, TerminalBlock, renderProse, NEUTRAL_CHIP_CLASS, KIND_CHIP_CLASS } from './CardSection'
65
- export type { CardSectionTone } from './CardSection'
66
- export { MultiSelectPicker } from './MultiSelectPicker'
67
- export type { MultiSelectPickerProps } from './MultiSelectPicker'
54
+ export { PageHeader } from "./PageHeader";
55
+ export { SummaryTile } from "./SummaryTile";
56
+ export type { SummaryTone } from "./SummaryTile";
57
+ export { Facet, FacetSection, FacetButton } from "./Facet";
58
+ export type { FacetTone } from "./Facet";
59
+ export { SortableTh, TH_CLASS } from "./SortableTh";
60
+ export type { SortDir } from "./SortableTh";
61
+ export { DistributionBar, DistributionLegendChip } from "./DistributionBar";
62
+ export type { DistributionSegment } from "./DistributionBar";
63
+ export { SearchPillInput } from "./SearchPillInput";
64
+ export type { SearchModifier, SearchPillInputProps } from "./SearchPillInput";
65
+ export {
66
+ CardSection,
67
+ CardBody,
68
+ TerminalBlock,
69
+ TerminalBlockLabel,
70
+ renderProse,
71
+ NEUTRAL_CHIP_CLASS,
72
+ KIND_CHIP_CLASS,
73
+ } from "./CardSection";
74
+ export type { CardSectionTone } from "./CardSection";
75
+ export { MultiSelectPicker } from "./MultiSelectPicker";
76
+ export type { MultiSelectPickerProps } from "./MultiSelectPicker";
@@ -7,6 +7,7 @@ import { FetchResult } from '../ui/FetchResult'
7
7
  import { PaneLoader } from '../ui/PaneLoader'
8
8
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
9
9
  import { clsx } from 'clsx'
10
+ import { Collapse } from '../ui/Collapse'
10
11
  import {
11
12
  ArrowLeft,
12
13
  ArrowRight,
@@ -2949,20 +2950,15 @@ function PodListFrame({
2949
2950
  {children}
2950
2951
  </div>
2951
2952
  {hasOverflow && (
2952
- <div
2953
- className={clsx(
2954
- 'grid transition-[grid-template-rows,opacity] duration-200',
2955
- expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
2956
- )}
2957
- style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
2958
- aria-hidden={!expanded || undefined}
2959
- >
2960
- <div className={clsx('min-h-0 overflow-hidden', expanded && 'max-h-[26rem] overflow-y-auto pr-1')} inert={!expanded || undefined}>
2953
+ <Collapse open={expanded}>
2954
+ {/* Long overflow lists scroll inside a capped box once open; the
2955
+ cap comes off while closed so the collapse measures to zero. */}
2956
+ <div className={clsx(expanded && 'max-h-[26rem] overflow-y-auto pr-1')}>
2961
2957
  <div className="space-y-2">
2962
2958
  {overflow}
2963
2959
  </div>
2964
2960
  </div>
2965
- </div>
2961
+ </Collapse>
2966
2962
  )}
2967
2963
  {toggle}
2968
2964
  </div>
@@ -1,10 +1,15 @@
1
1
  import { useState, useEffect, useRef } from 'react'
2
- import { DURATION_NORMAL } from '../utils/animation'
2
+ import { DURATION_NORMAL, prefersReducedMotion } from '../utils/animation'
3
3
 
4
4
  /**
5
5
  * Delays React unmount until exit animation completes.
6
6
  * - `shouldRender`: whether the component should be in the DOM
7
7
  * - `isOpen`: drives CSS transition classes (false → closed state, true → open state)
8
+ *
9
+ * Pass the EXIT duration (overlayExitMs('dialog') etc.) — that is how long the
10
+ * closed state stays mounted. Under reduced motion the app stylesheet zeroes the
11
+ * transition, so the hook disposes at logical close instead of holding an
12
+ * invisible overlay (and its focus / scroll lock) for the exit window.
8
13
  */
9
14
  export function useAnimatedUnmount(isVisible: boolean, duration = DURATION_NORMAL) {
10
15
  const [shouldRender, setShouldRender] = useState(isVisible)
@@ -21,8 +26,21 @@ export function useAnimatedUnmount(isVisible: boolean, duration = DURATION_NORMA
21
26
  return () => cancelAnimationFrame(rafRef.current)
22
27
  } else if (shouldRender) {
23
28
  setIsOpen(false)
24
- const t = setTimeout(() => setShouldRender(false), duration)
25
- return () => clearTimeout(t)
29
+ if (prefersReducedMotion()) {
30
+ setShouldRender(false)
31
+ return
32
+ }
33
+ // Start the disposal clock only once the closed state has painted (same
34
+ // double rAF as the open path). A heavy overlay can take a slow frame to
35
+ // commit its closed classes; a timer started at logical close would then
36
+ // unmount it before the exit transition has had its full duration.
37
+ let t = 0
38
+ rafRef.current = requestAnimationFrame(() => {
39
+ rafRef.current = requestAnimationFrame(() => {
40
+ t = window.setTimeout(() => setShouldRender(false), duration)
41
+ })
42
+ })
43
+ return () => { cancelAnimationFrame(rafRef.current); clearTimeout(t) }
26
44
  }
27
45
  }, [isVisible, duration]) // eslint-disable-line react-hooks/exhaustive-deps — shouldRender intentionally omitted to avoid re-triggering cleanup
28
46
 
@@ -142,25 +142,6 @@
142
142
  padding: 0.75rem;
143
143
  }
144
144
 
145
- /* ── ISSUE ROW DETAILS ── */
146
-
147
- .issue-details-motion {
148
- display: grid;
149
- grid-template-rows: 0fr;
150
- overflow: hidden;
151
- transition: grid-template-rows 200ms ease-out;
152
- }
153
-
154
- .issue-details-motion-open {
155
- grid-template-rows: 1fr;
156
- }
157
-
158
- @starting-style {
159
- .issue-details-motion-open {
160
- grid-template-rows: 0fr;
161
- }
162
- }
163
-
164
145
  /* ── SUBTLE BORDERS ── */
165
146
 
166
147
  .table-divide-subtle > * + * {
@@ -1,27 +1,57 @@
1
1
  /**
2
2
  * Central animation presets for consistent motion across the UI.
3
3
  *
4
- * All animations use GPU-composited properties only (translate, scale, opacity)
5
- * to guarantee 60fps. The iOS-like spring easing gives a fast-in, gentle-out feel.
4
+ * One curve, a handful of durations, and class fragments that apply them.
5
+ * Every surface that opens, closes, expands or collapses takes its timing
6
+ * from here — the hub (radar-hub-web) re-exports this module so Radar Cloud
7
+ * chrome and the embedded Radar views move alike.
8
+ *
9
+ * Property choice: geometry that has to move (grid-template-rows on a
10
+ * disclosure, width on a drawer) is animated directly; everything else is
11
+ * transform + opacity so it stays GPU-composited.
6
12
  */
7
13
 
8
14
  // -- Easing ------------------------------------------------------------------
9
15
 
16
+ /** The one UI curve. iOS-style decelerate: fast out of the gate, gentle
17
+ * settle. Used for disclosures, menus, dialogs, sheets and drawers alike so
18
+ * a click reads as the cause of the motion and the content is legible early.
19
+ * Chosen over Material's standard curve in a side-by-side on real content. */
20
+ export const CSS_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
21
+ /** Alias with the intent in the name; prefer it in new code. */
22
+ export const EASE_UI = CSS_EASE
23
+
10
24
  // Tailwind-safe easing (no spaces — Tailwind splits class strings on whitespace)
11
25
  const TW_EASE = 'ease-[cubic-bezier(0.32,0.72,0,1)]'
12
-
13
- /** Raw CSS easing for inline transition strings */
14
- export const CSS_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
26
+ /** The same curve as a Tailwind class fragment, for class-string composition. */
27
+ export const TW_EASE_UI = TW_EASE
15
28
 
16
29
  // -- Durations (ms) ----------------------------------------------------------
17
30
 
18
- /** Standard UI transitions: overlays, panels, toasts */
31
+ /** Standard UI transitions: drawers, toasts. */
19
32
  export const DURATION_NORMAL = 300
20
- /** Dock height, subtle layout shifts */
33
+ /** Dock height, subtle layout shifts. Deliberately fast: the dock is direct
34
+ * manipulation the user performs dozens of times a day. */
21
35
  export const DURATION_DOCK = 150
22
36
  /** Toast exit animation */
23
37
  export const DURATION_TOAST_EXIT = 200
24
38
 
39
+ /** In-flow disclosure (Collapse / drawer sections / row expanders): panel
40
+ * and caret share this, open and close alike. Symmetric on purpose — an
41
+ * interrupted toggle reverses cleanly when both directions run one clock. */
42
+ export const DURATION_DISCLOSURE = 300
43
+
44
+ /** Menus, popovers, listboxes. Exits are shorter than entrances: a dismissal
45
+ * should feel immediate. */
46
+ export const DURATION_MENU_IN = 140
47
+ export const DURATION_MENU_OUT = 100
48
+ /** Modal dialogs and command palettes. */
49
+ export const DURATION_DIALOG_IN = 220
50
+ export const DURATION_DIALOG_OUT = 160
51
+ /** Bottom sheets: more travel, a little more time. */
52
+ export const DURATION_SHEET_IN = 280
53
+ export const DURATION_SHEET_OUT = 220
54
+
25
55
  /** Drawer ↔ fullscreen expand/collapse morph. Longer than DURATION_NORMAL so a
26
56
  * large width change advances in small per-frame steps (reads as fluid, not
27
57
  * abrupt). Drives the frame width, the content crossfade, and the JS window
@@ -32,19 +62,69 @@ export const DURATION_DRAWER_MORPH = 260
32
62
  * crossfade fixed the reflow that originally needed the gentle start. */
33
63
  export const EASE_DRAWER_MORPH = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
34
64
 
65
+ // -- Reduced motion -----------------------------------------------------------
66
+
67
+ /** True when the OS asks for reduced motion. Presence hooks use it to dispose
68
+ * overlays at logical close instead of after an exit transition that the app
69
+ * stylesheet has already zeroed. SSR- and test-safe. */
70
+ export function prefersReducedMotion(): boolean {
71
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
72
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
73
+ }
74
+
35
75
  // -- Tailwind class presets ---------------------------------------------------
36
76
  // Reusable class fragments — import and spread into clsx() calls.
37
77
 
78
+ /** Disclosure panel: grid-template-rows 0fr ↔ 1fr on the wrapper. Pair with
79
+ * TRANSITION_CHEVRON on the caret so both run one clock. */
80
+ export const TRANSITION_DISCLOSURE =
81
+ `transition-[grid-template-rows] duration-300 ${TW_EASE} motion-reduce:transition-none`
82
+
83
+ /** Disclosure caret (rotate). Same duration and curve as the panel it opens. */
84
+ export const TRANSITION_CHEVRON =
85
+ `transition-transform duration-300 ${TW_EASE} motion-reduce:transition-none`
86
+
38
87
  /** Drawer slide from right — use with translate-x-0/translate-x-full + opacity.
39
88
  * Also handles expand/collapse (width) so a single transition covers both. */
40
89
  export const TRANSITION_DRAWER =
41
- `transition-[translate,opacity,width] duration-300 ${TW_EASE} will-change-[transform,width]`
90
+ `transition-[translate,opacity,width] duration-300 ${TW_EASE} will-change-[transform,width] motion-reduce:transition-none`
42
91
 
43
-
44
- /** Overlay backdrop fade */
92
+ /** Overlay backdrop fade. Duration comes from the presence state (see
93
+ * overlayTransitionStyle) so the exit can be shorter than the entrance. */
45
94
  export const TRANSITION_BACKDROP =
46
- 'transition-opacity duration-200'
95
+ `transition-opacity ${TW_EASE} motion-reduce:transition-none`
47
96
 
48
- /** Overlay panel scale + fade — use with scale-100/scale-[0.97] + opacity */
97
+ /** Overlay panel scale + fade — use with scale-100/scale-[0.97] + opacity.
98
+ * Duration comes from overlayTransitionStyle, not from this fragment. */
49
99
  export const TRANSITION_PANEL =
50
- `transition-[translate,scale,opacity] duration-300 ${TW_EASE}`
100
+ `transition-[translate,scale,opacity] ${TW_EASE} motion-reduce:transition-none`
101
+
102
+ /** Menu / popover: translate + scale + opacity from its anchor corner. Pair
103
+ * with a transform-origin utility so it grows from the trigger. */
104
+ export const TRANSITION_MENU =
105
+ `transition-[translate,scale,opacity] ${TW_EASE} motion-reduce:transition-none`
106
+
107
+ export type OverlayKind = 'menu' | 'dialog' | 'sheet' | 'drawer'
108
+
109
+ const OVERLAY_DURATIONS: Record<OverlayKind, { in: number; out: number }> = {
110
+ menu: { in: DURATION_MENU_IN, out: DURATION_MENU_OUT },
111
+ dialog: { in: DURATION_DIALOG_IN, out: DURATION_DIALOG_OUT },
112
+ sheet: { in: DURATION_SHEET_IN, out: DURATION_SHEET_OUT },
113
+ // Side drawers keep their symmetric slide (TRANSITION_DRAWER); this entry
114
+ // gives their backdrop the same clock.
115
+ drawer: { in: DURATION_NORMAL, out: DURATION_NORMAL },
116
+ }
117
+
118
+ /** Exit duration for an overlay kind — what useAnimatedUnmount should wait. */
119
+ export function overlayExitMs(kind: OverlayKind): number {
120
+ return OVERLAY_DURATIONS[kind].out
121
+ }
122
+
123
+ /** Inline style that gives an overlay its entrance or exit duration. Apply to
124
+ * the element that carries TRANSITION_PANEL / TRANSITION_MENU / TRANSITION_BACKDROP;
125
+ * the class fragments deliberately carry no duration so the two directions
126
+ * can differ. */
127
+ export function overlayTransitionStyle(isOpen: boolean, kind: OverlayKind): { transitionDuration: string } {
128
+ const d = OVERLAY_DURATIONS[kind]
129
+ return { transitionDuration: `${isOpen ? d.in : d.out}ms` }
130
+ }