@skyhook-io/k8s-ui 1.14.12 → 1.15.0

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  3. package/src/components/gitops/RollbackDialog.tsx +4 -3
  4. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  5. package/src/components/gitops/health-provenance.test.ts +1 -1
  6. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  7. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  8. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  9. package/src/components/resources/ResourcesView.tsx +4 -0
  10. package/src/components/resources/printer-columns.test.ts +1 -1
  11. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  12. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  13. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  14. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  15. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  16. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  17. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  18. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  19. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  20. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  21. package/src/components/resources/renderers/index.ts +2 -0
  22. package/src/components/resources/resource-utils-kueue.ts +14 -0
  23. package/src/components/resources/resource-utils-ray.ts +14 -4
  24. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  25. package/src/components/shared/ResourceActionsBar.tsx +4 -2
  26. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  27. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  28. package/src/components/shared/SetImageDialog.tsx +4 -2
  29. package/src/components/shared/revision-role-badges.test.ts +1 -1
  30. package/src/components/trace/reachGraphModel.test.ts +1 -0
  31. package/src/components/trace/reachInspector.test.ts +4 -9
  32. package/src/components/ui/ConfirmDialog.tsx +5 -3
  33. package/src/components/ui/DialogPortal.test.tsx +279 -0
  34. package/src/components/ui/DialogPortal.tsx +147 -9
  35. package/src/components/ui/YamlReview.tsx +4 -1
  36. package/src/components/ui/drawer-components.test.tsx +14 -1
  37. package/src/components/ui/drawer-components.tsx +50 -40
@@ -315,11 +315,9 @@ describe('getResourceStatus — colliding plurals', () => {
315
315
  // fine; being scored with Argo's vocabulary is not, because that attaches a
316
316
  // HealthLevel derived from a phase Katib never reports.
317
317
  it('does not score a Katib Experiment with the Argo vocabulary', () => {
318
- const katib = getResourceStatus('experiments', {
319
- apiVersion: 'kubeflow.org/v1beta1',
320
- status: { conditions: [{ type: 'Running', status: 'True' }] },
321
- })
322
- expect(katib?.level).toBeUndefined()
318
+ const katib = { apiVersion: 'kubeflow.org/v1beta1', status: { conditions: [{ type: 'Running', status: 'True' }] } }
319
+ // Read the way any unrelated CRD with the same status would be.
320
+ expect(getResourceStatus('experiments', katib)).toEqual(getResourceStatus('trainingruns', katib))
323
321
  })
324
322
 
325
323
  it('fabricates no engine status for a third-party backups CRD', () => {
@@ -1080,3 +1078,12 @@ it('uses native RayCluster detail only for ray.io/v1', () => {
1080
1078
  if (apiVersion !== 'ray.io/v1') expect(html).toContain('2.55.0')
1081
1079
  }
1082
1080
  })
1081
+
1082
+ it('uses RayJob native detail only for the supported exact API', () => {
1083
+ for (const apiVersion of ['ray.io/v1', 'foreign.io/v1', 'ray.io/v99', 'ray.io/v1alpha1']) {
1084
+ const data = { apiVersion, kind: 'RayJob', metadata: { name: 'train', namespace: 'ml' }, spec: { customField: 'native-fallback' } }
1085
+ const html = renderKind('rayjobs', data, 'ml')
1086
+ expect(html.includes('RayJob execution')).toBe(apiVersion === 'ray.io/v1')
1087
+ if (apiVersion !== 'ray.io/v1') { expect(html).toContain('native-fallback'); expect(getResourceStatus('rayjobs', data)).toBeNull() }
1088
+ }
1089
+ })
@@ -118,6 +118,7 @@ import {
118
118
  KueueWorkloadRenderer,
119
119
  RayServiceRenderer,
120
120
  RayClusterRenderer,
121
+ RayJobRenderer,
121
122
  AdmissionCheckRenderer,
122
123
  ProvisioningRequestRenderer,
123
124
  LocalQueueRenderer,
@@ -312,6 +313,9 @@ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
312
313
  * When an override is not provided, the base (shared) renderer is used.
313
314
  */
314
315
  export interface RendererOverrides {
316
+ JobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
317
+ JobSetRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
318
+ RayJobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
315
319
  RayClusterRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
316
320
  RayServiceRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
317
321
  KueueWorkloadRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
@@ -445,7 +449,7 @@ const KNOWN_KINDS = new Set([
445
449
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
446
450
  'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows',
447
451
  'jobsets',
448
- 'rayclusters', 'rayservices', 'workloads', 'localqueues', 'clusterqueues', 'admissionchecks', 'provisioningrequests',
452
+ 'rayclusters', 'rayservices', 'rayjobs', 'workloads', 'localqueues', 'clusterqueues', 'admissionchecks', 'provisioningrequests',
449
453
  'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims',
450
454
  'rollouts', 'analysisruns', 'analysistemplates', 'clusteranalysistemplates', 'experiments', 'certificates', 'workflows', 'persistentvolumes',
451
455
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
@@ -700,7 +704,7 @@ export function ResourceRendererDispatch({
700
704
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
701
705
  || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'jobsets'
702
706
  || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
703
- || kind === 'rayclusters' || kind === 'rayservices' || kind === 'admissionchecks' || kind === 'provisioningrequests'
707
+ || kind === 'rayjobs' || kind === 'rayclusters' || kind === 'rayservices' || kind === 'admissionchecks' || kind === 'provisioningrequests'
704
708
  || kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
705
709
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
706
710
  const groupGatedMatched =
@@ -721,6 +725,7 @@ export function ResourceRendererDispatch({
721
725
  || (kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io'))
722
726
  || ((kind === 'machines' || kind === 'machinesets')
723
727
  && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io'))
728
+ || (kind === 'rayjobs' && data?.apiVersion === 'ray.io/v1')
724
729
  || (kind === 'rayclusters' && data?.apiVersion === 'ray.io/v1')
725
730
  || (kind === 'rayservices' && data?.apiVersion === 'ray.io/v1')
726
731
  || (kind === 'jobsets' && isJobSetV1Alpha2(data))
@@ -748,6 +753,9 @@ export function ResourceRendererDispatch({
748
753
 
749
754
  const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
750
755
 
756
+ const JobComp = rendererOverrides?.JobRenderer ?? JobRenderer
757
+ const JobSetComp = rendererOverrides?.JobSetRenderer ?? JobSetRenderer
758
+ const RayJobComp = rendererOverrides?.RayJobRenderer ?? RayJobRenderer
751
759
  const RayClusterComp = rendererOverrides?.RayClusterRenderer ?? RayClusterRenderer
752
760
  const RayServiceComp = rendererOverrides?.RayServiceRenderer ?? RayServiceRenderer
753
761
  const KueueWorkloadComp = rendererOverrides?.KueueWorkloadRenderer ?? KueueWorkloadRenderer
@@ -789,7 +797,7 @@ export function ResourceRendererDispatch({
789
797
  const sidebarContent = showCommonSections && (
790
798
  <>
791
799
  <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
792
- {kind !== 'events' && <EventsSection events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
800
+ {kind !== 'events' && <EventsSection fullscreen={!!renderSidebar} events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
793
801
  <LabelsSection data={data} />
794
802
  <AnnotationsSection data={data} />
795
803
  <MetadataSection data={data} />
@@ -818,7 +826,8 @@ export function ResourceRendererDispatch({
818
826
  {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
819
827
  {kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
820
828
  {kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
821
- {kind === 'jobs' && !nonCoreJobFallthrough && <JobRenderer data={data} />}
829
+ {kind === 'jobs' && !nonCoreJobFallthrough && <JobComp data={data} onNavigate={onNavigate} />}
830
+ {kind === 'rayjobs' && data?.apiVersion === 'ray.io/v1' && <RayJobComp data={data} onNavigate={onNavigate} />}
822
831
  {kind === 'rayclusters' && data?.apiVersion === 'ray.io/v1' && <RayClusterComp data={data} onNavigate={onNavigate} />}
823
832
  {kind === 'rayservices' && data?.apiVersion === 'ray.io/v1' && <RayServiceComp data={data} onNavigate={onNavigate} />}
824
833
  {kind === 'workloads' && isApiGroup(data?.apiVersion, 'kueue.x-k8s.io') && <KueueWorkloadComp data={data} onNavigate={onNavigate} />}
@@ -826,7 +835,7 @@ export function ResourceRendererDispatch({
826
835
  {kind === 'provisioningrequests' && ['autoscaling.x-k8s.io/v1', 'autoscaling.x-k8s.io/v1beta1'].includes(data?.apiVersion) && <ProvisioningRequestRenderer data={data} onNavigate={onNavigate} />}
827
836
  {kind === 'localqueues' && isKueueQueueResource(data) && <LocalQueueRenderer data={data} onNavigate={onNavigate} />}
828
837
  {kind === 'clusterqueues' && isKueueQueueResource(data) && <ClusterQueueRenderer data={data} onNavigate={onNavigate} />}
829
- {kind === 'jobsets' && isJobSetV1Alpha2(data) && <JobSetRenderer data={data} />}
838
+ {kind === 'jobsets' && isJobSetV1Alpha2(data) && <JobSetComp data={data} onNavigate={onNavigate} />}
830
839
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
831
840
  {kind === 'cronworkflows' && <CronWorkflowRenderer data={data} onNavigate={onNavigate} />}
832
841
  {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} hpaDiagnosis={hpaDiagnosis} />}
@@ -1123,7 +1132,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1123
1132
  if (k === 'admissionchecks' && isKueueQueueResource(data)) return getAdmissionCheckStatus(data)
1124
1133
  if (k === 'provisioningrequests' && ['autoscaling.x-k8s.io/v1', 'autoscaling.x-k8s.io/v1beta1'].includes(data?.apiVersion)) return getProvisioningRequestStatus(data)
1125
1134
  if (k === 'rayclusters' && data?.apiVersion === 'ray.io/v1') return getRayClusterStatus(data)
1126
- if (k === 'rayjobs' && data?.apiVersion?.startsWith('ray.io/')) return getRayJobStatus(data)
1135
+ if (k === 'rayjobs' && data?.apiVersion === 'ray.io/v1') return getRayJobStatus(data)
1127
1136
  if (k === 'rayservices' && data?.apiVersion === 'ray.io/v1') return getRayServiceStatus(data)
1128
1137
  if (k === 'raycronjobs' && data?.apiVersion?.startsWith('ray.io/')) return getRayCronJobStatus(data)
1129
1138
  if (k === 'leaderworkersets' && data?.apiVersion?.startsWith('leaderworkerset.x-k8s.io/')) return getLeaderWorkerSetStatus(data)
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, useId } from 'react'
2
2
  import {
3
3
  AlertTriangle,
4
4
  ExternalLink,
@@ -146,6 +146,7 @@ export function SetImageDialog({
146
146
  onLoad,
147
147
  onConfirm,
148
148
  }: SetImageDialogProps) {
149
+ const titleId = useId()
149
150
  const [inventory, setInventory] = useState<WorkloadImageInventory | null>(
150
151
  null,
151
152
  )
@@ -289,6 +290,7 @@ export function SetImageDialog({
289
290
 
290
291
  return (
291
292
  <DialogPortal
293
+ ariaLabelledBy={titleId}
292
294
  open={open}
293
295
  onClose={busy ? () => {} : onClose}
294
296
  closable={!busy}
@@ -296,7 +298,7 @@ export function SetImageDialog({
296
298
  >
297
299
  <div className="flex items-start justify-between gap-4 border-b border-theme-border px-5 py-4">
298
300
  <div className="min-w-0">
299
- <h2 className="text-sm font-semibold text-theme-text-primary">
301
+ <h2 id={titleId} className="text-sm font-semibold text-theme-text-primary">
300
302
  Update container images
301
303
  </h2>
302
304
  <p className="mt-0.5 truncate text-xs text-theme-text-tertiary">
@@ -25,7 +25,7 @@ describe('offersPromoteAfterRollback', () => {
25
25
  })
26
26
 
27
27
  function revision(overrides: Partial<WorkloadRevision>): WorkloadRevision {
28
- return { number: 1, image: 'web:v1', createdAt: '2026-08-07T00:00:00Z', ...overrides }
28
+ return { number: 1, image: 'web:v1', createdAt: '2026-08-07T00:00:00Z', isCurrent: false, replicas: 0, ...overrides }
29
29
  }
30
30
 
31
31
  describe('revisionRoleBadges', () => {
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import type { Mark } from './reachMarks'
2
3
  import { buildGraph, shortEvidence, noteHeadline, hopEvidenceFor, originEntryEvidence, originNoEvidenceLabel, POD_ROW_MAX, PILL_MAX_PX } from './reachGraphModel'
3
4
  import { buildOrigins } from './reachOrigins'
4
5
  import type { Trace, RouteResult, PodStatus, ProbeResult } from './types'
@@ -16,7 +16,7 @@ function mk(pods: PodStatus[], probes: ProbeResult[]): Trace {
16
16
  // The producer attaches a concrete request to every probed route; without
17
17
  // one the server has nothing to send, so a fixture that omits it is not a
18
18
  // realistic trace.
19
- routes: [{ route: 'GET /', target: 'shop:80', outcome: 'verified', confidence: 'real', inClusterRequest: { scheme: 'http', path: '/' } }],
19
+ routes: [{ route: 'GET /', target: 'shop:80', outcome: 'verified', confidence: 'real', inClusterRequest: { protocol: 'http', scheme: 'http', path: '/' } }],
20
20
  downstream: [
21
21
  { resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'service', findings: [], config: { clusterIP: '10.96.0.1', selector: { app: 'shop' } } },
22
22
  {
@@ -39,10 +39,6 @@ function podsNodeId(t: Trace): string {
39
39
  const g = buildGraph({ trace: t, route: route(), origin: buildOrigins(t).find((o) => o.id === 'incluster')! })
40
40
  return g.nodes.find((n) => n.kind === 'PODS')!.id
41
41
  }
42
- function podsEdgeId(t: Trace): string {
43
- const g = buildGraph({ trace: t, route: route(), origin: buildOrigins(t).find((o) => o.id === 'incluster')! })
44
- return g.edges.find((e) => e.label === 'selects')!.id
45
- }
46
42
 
47
43
  function ctx(t: Trace, originId: string, r = route()) {
48
44
  const origins = buildOrigins(t)
@@ -76,7 +72,6 @@ describe('the diagnosis is always present', () => {
76
72
  // The panel always answers the path question; a next-step block is offered
77
73
  // only when there is genuinely a next step.
78
74
  expect(s.path.body).toBeTruthy()
79
- expect(s.resource).toBeUndefined()
80
75
  })
81
76
 
82
77
  // Every hop is reported whether or not anything was clicked; a selection only
@@ -241,7 +236,7 @@ describe('verdict band', () => {
241
236
  const t = mk([pod('a', true, '10.0.0.1')], [])
242
237
  t.verdict = 'degraded'
243
238
  t.routes = []
244
- const v = buildVerdict(t, undefined, buildOrigins(t))
239
+ const v = buildVerdict(t, undefined)
245
240
  expect(v.tone).toBe('degraded')
246
241
  })
247
242
 
@@ -249,7 +244,7 @@ describe('verdict band', () => {
249
244
  const t = mk([pod('a', true, '10.0.0.1')], [])
250
245
  t.headline = 'Configuration only - not yet tested'
251
246
  t.diagnosis = { summary: 'Accepted: NoMatchingListenerHostname - no hostname intersections' }
252
- const v = buildVerdict(t, undefined, buildOrigins(t))
247
+ const v = buildVerdict(t, undefined)
253
248
  expect(v.problem).toMatch(/NoMatchingListenerHostname/)
254
249
  // and it is not duplicated into the body
255
250
  expect(v.body).toBe('')
@@ -723,7 +718,7 @@ describe('selecting a context entry opens it', () => {
723
718
  describe('a coverage statement is not a fault', () => {
724
719
  it('the verdict keeps it on the wire but marks it coverage-class', () => {
725
720
  const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
726
- t.diagnosis = { class: 'coverage', summary: "reachable via API server - the real-traffic path wasn't confirmed from here" } as never
721
+ t.diagnosis = { class: 'coverage', summary: "reachable via API server - the real-traffic path wasn't confirmed from here" }
727
722
  // buildVerdict still surfaces it for consumers that want the sentence...
728
723
  const v = buildVerdict(t, route({ outcome: 'reached', confidence: 'indirect' }), { originId: 'apiserver', originName: 'API-server proxy' })
729
724
  expect(v.problem).toBeTruthy()
@@ -1,4 +1,4 @@
1
- import { ReactNode } from 'react'
1
+ import { ReactNode, useId } from 'react'
2
2
  import { AlertTriangle, X } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { SEVERITY_TEXT, SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
@@ -43,9 +43,10 @@ export function ConfirmDialog({
43
43
  const canClose = !isLoading || isClosable
44
44
  const isDanger = variant === 'danger'
45
45
  const severity = isDanger ? 'error' : 'warning'
46
+ const titleId = useId()
46
47
 
47
48
  return (
48
- <DialogPortal open={open} onClose={onClose} closable={canClose} className={clsx('w-full', className ?? 'max-w-md')}>
49
+ <DialogPortal open={open} onClose={onClose} closable={canClose} className={clsx('w-full', className ?? 'max-w-md')} ariaLabelledBy={titleId}>
49
50
  {/* Header */}
50
51
  <div className="flex items-start gap-3 p-4 border-b border-theme-border">
51
52
  <div
@@ -57,12 +58,13 @@ export function ConfirmDialog({
57
58
  <AlertTriangle className={clsx('w-5 h-5', SEVERITY_TEXT[severity])} />
58
59
  </div>
59
60
  <div className="flex-1 min-w-0">
60
- <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
61
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">{title}</h3>
61
62
  <p className="text-sm text-theme-text-secondary mt-1">{message}</p>
62
63
  </div>
63
64
  <button
64
65
  onClick={onClose}
65
66
  disabled={!canClose}
67
+ aria-label="Close"
66
68
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
67
69
  >
68
70
  <X className="w-5 h-5" />
@@ -0,0 +1,279 @@
1
+ // @vitest-environment jsdom
2
+ import { act, StrictMode, useState, type ReactNode } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { DialogPortal } from './DialogPortal'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ const roots = new Set<Root>()
10
+
11
+ async function mount(node: ReactNode) {
12
+ const opener = document.createElement('button')
13
+ opener.textContent = 'opener'
14
+ document.body.appendChild(opener)
15
+ opener.focus()
16
+ const host = document.createElement('div')
17
+ document.body.appendChild(host)
18
+ const root = createRoot(host)
19
+ roots.add(root)
20
+ await act(async () => root.render(node))
21
+ return { root, opener, render: (next: ReactNode) => act(async () => root.render(next)) }
22
+ }
23
+
24
+ const dialog = () => document.querySelector<HTMLElement>('[role="dialog"]')
25
+ const guards = () => Array.from(document.querySelectorAll<HTMLElement>('[data-focus-guard]'))
26
+
27
+ function pressTab(shiftKey = false) {
28
+ const target = document.activeElement ?? document.body
29
+ const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey, bubbles: true, cancelable: true })
30
+ target.dispatchEvent(event)
31
+ return event
32
+ }
33
+
34
+ afterEach(async () => {
35
+ vi.restoreAllMocks()
36
+ for (const root of roots) await act(async () => root.unmount())
37
+ roots.clear()
38
+ document.body.replaceChildren()
39
+ })
40
+
41
+ function Dialog({ open, onClose = () => {}, children }: { open: boolean; onClose?: () => void; children?: ReactNode }) {
42
+ return (
43
+ <DialogPortal open={open} onClose={onClose} ariaLabelledBy="dialog-title">
44
+ <h3 id="dialog-title">Title</h3>
45
+ {children}
46
+ </DialogPortal>
47
+ )
48
+ }
49
+
50
+ describe('DialogPortal accessibility', () => {
51
+ it('moves focus into a dialog that was mounted closed and then opened', async () => {
52
+ const { render } = await mount(<Dialog open={false} />)
53
+ expect(dialog()).toBeNull()
54
+ await render(<Dialog open />)
55
+ expect(document.activeElement).toBe(dialog())
56
+ })
57
+
58
+ it('leaves focus on a child that autoFocused', async () => {
59
+ const { render } = await mount(<Dialog open={false} />)
60
+ await render(<Dialog open><input autoFocus aria-label="replicas" /></Dialog>)
61
+ expect(document.activeElement).toBe(document.querySelector('input'))
62
+ })
63
+
64
+ it('names the dialog from ariaLabelledBy, or ariaLabel when no title is referenced', async () => {
65
+ await mount(<Dialog open />)
66
+ expect(dialog()?.getAttribute('aria-labelledby')).toBe('dialog-title')
67
+ expect(dialog()?.hasAttribute('aria-label')).toBe(false)
68
+ await act(async () => { for (const root of roots) root.unmount() })
69
+ roots.clear()
70
+ await mount(<DialogPortal open onClose={() => {}} ariaLabel="Scale workload">x</DialogPortal>)
71
+ expect(dialog()?.getAttribute('aria-label')).toBe('Scale workload')
72
+ })
73
+
74
+ it('restores focus to the opener at logical close, before the exit finishes', async () => {
75
+ const { render, opener } = await mount(<Dialog open={false} />)
76
+ await render(<Dialog open><button>inside</button></Dialog>)
77
+ expect(document.activeElement).toBe(dialog())
78
+ await render(<Dialog open={false}><button>inside</button></Dialog>)
79
+ expect(dialog()).not.toBeNull()
80
+ expect(dialog()?.closest('[inert]')).not.toBeNull()
81
+ expect(document.activeElement).toBe(opener)
82
+ })
83
+
84
+ it('restores focus when a conditionally-mounted dialog unmounts', async () => {
85
+ function Host() {
86
+ const [show, setShow] = useState(true)
87
+ return show ? <Dialog open onClose={() => setShow(false)}><button>inside</button></Dialog> : null
88
+ }
89
+ const { opener } = await mount(<Host />)
90
+ expect(document.activeElement).toBe(dialog())
91
+ await act(async () => {
92
+ dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
93
+ })
94
+ expect(dialog()).toBeNull()
95
+ expect(document.activeElement).toBe(opener)
96
+ })
97
+
98
+ it('does not steal focus back from an autoFocus child under StrictMode', async () => {
99
+ const { opener } = await mount(
100
+ <StrictMode>
101
+ <Dialog open><input autoFocus aria-label="name" /></Dialog>
102
+ </StrictMode>,
103
+ )
104
+ await act(async () => {})
105
+ expect(document.activeElement).toBe(document.querySelector('input'))
106
+ expect(document.activeElement).not.toBe(opener)
107
+ })
108
+
109
+ it('does not pull focus back when the close moved it elsewhere', async () => {
110
+ // The destination takes focus in the same commit as the close — as a
111
+ // navigated-to view's autoFocus does — so the restore microtask sees it.
112
+ const { render, opener } = await mount(<Dialog open={false} />)
113
+ await render(<Dialog open><button>inside</button></Dialog>)
114
+ await render(<><Dialog open={false}><button>inside</button></Dialog><input autoFocus aria-label="destination" /></>)
115
+ const destination = document.querySelector('input[aria-label="destination"]')
116
+ expect(document.activeElement).toBe(destination)
117
+ expect(document.activeElement).not.toBe(opener)
118
+ })
119
+
120
+ it('wraps focus that leaves past either edge of the panel', async () => {
121
+ await mount(
122
+ <Dialog open>
123
+ <button>first</button>
124
+ <button disabled>disabled</button>
125
+ <button>last</button>
126
+ </Dialog>,
127
+ )
128
+ const [first, , last] = Array.from(dialog()!.querySelectorAll('button'))
129
+ const [startGuard, endGuard] = guards()
130
+ last.focus()
131
+ endGuard.focus()
132
+ expect(document.activeElement).toBe(first)
133
+ startGuard.focus()
134
+ expect(document.activeElement).toBe(last)
135
+ })
136
+
137
+ it('enters at the near edge when focus arrives at a guard from outside', async () => {
138
+ await mount(<Dialog open><button>first</button><button>last</button></Dialog>)
139
+ const [first, last] = Array.from(dialog()!.querySelectorAll('button'))
140
+ const [startGuard, endGuard] = guards()
141
+ ;(document.activeElement as HTMLElement).blur()
142
+ startGuard.focus()
143
+ expect(document.activeElement).toBe(first)
144
+ ;(document.activeElement as HTMLElement).blur()
145
+ endGuard.focus()
146
+ expect(document.activeElement).toBe(last)
147
+ })
148
+
149
+ it('keeps focus on the panel when it has nothing tabbable', async () => {
150
+ await mount(<Dialog open />)
151
+ guards()[1].focus()
152
+ expect(document.activeElement).toBe(dialog())
153
+ })
154
+
155
+ it('never intercepts Tab inside the panel, leaving the order to the browser', async () => {
156
+ await mount(<Dialog open><button>first</button><button>last</button></Dialog>)
157
+ const [first, last] = Array.from(dialog()!.querySelectorAll('button'))
158
+ last.focus()
159
+ expect(pressTab().defaultPrevented).toBe(false)
160
+ first.focus()
161
+ expect(pressTab(true).defaultPrevented).toBe(false)
162
+ })
163
+
164
+ it('restores focus to the opener after an autoFocus child took it, however the dialog mounted', async () => {
165
+ const { render, opener } = await mount(<Dialog open={false} />)
166
+ await render(<Dialog open><input autoFocus aria-label="replicas" /></Dialog>)
167
+ expect(document.activeElement).toBe(document.querySelector('input'))
168
+ await render(<Dialog open={false}><input autoFocus aria-label="replicas" /></Dialog>)
169
+ expect(document.activeElement).toBe(opener)
170
+
171
+ function Host() {
172
+ const [show, setShow] = useState(true)
173
+ return show ? <Dialog open onClose={() => setShow(false)}><input autoFocus aria-label="name" /></Dialog> : null
174
+ }
175
+ await act(async () => { for (const root of roots) root.unmount() })
176
+ roots.clear()
177
+ const second = await mount(<Host />)
178
+ expect(document.activeElement).toBe(document.querySelector('input'))
179
+ await act(async () => {
180
+ document.querySelector('input')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
181
+ })
182
+ expect(dialog()).toBeNull()
183
+ expect(document.activeElement).toBe(second.opener)
184
+ })
185
+
186
+ it('wraps to the visible edges, skipping CSS-hidden controls', async () => {
187
+ // jsdom has no layout; stand in for the browser's visibility check.
188
+ const proto = HTMLElement.prototype
189
+ const original = Object.getOwnPropertyDescriptor(proto, 'checkVisibility')
190
+ proto.checkVisibility = function (this: HTMLElement) { return this.style.display !== 'none' }
191
+ try {
192
+ await mount(
193
+ <Dialog open>
194
+ <button style={{ display: 'none' }}>hidden first</button>
195
+ <button>first</button>
196
+ <button>last</button>
197
+ <button style={{ display: 'none' }}>hidden last</button>
198
+ </Dialog>,
199
+ )
200
+ const [, first, last] = Array.from(dialog()!.querySelectorAll('button'))
201
+ const [startGuard, endGuard] = guards()
202
+ last.focus()
203
+ endGuard.focus()
204
+ expect(document.activeElement).toBe(first)
205
+ startGuard.focus()
206
+ expect(document.activeElement).toBe(last)
207
+ } finally {
208
+ if (original) Object.defineProperty(proto, 'checkVisibility', original)
209
+ else Reflect.deleteProperty(proto, 'checkVisibility')
210
+ }
211
+ })
212
+
213
+ it('pulls a Tab back into the dialog after the focused control was disabled', async () => {
214
+ await mount(
215
+ <Dialog open>
216
+ <button>first</button>
217
+ <button>last</button>
218
+ </Dialog>,
219
+ )
220
+ const [first, last] = Array.from(dialog()!.querySelectorAll('button'))
221
+ ;(document.activeElement as HTMLElement).blur()
222
+ expect(document.activeElement).toBe(document.body)
223
+ expect(pressTab().defaultPrevented).toBe(true)
224
+ expect(document.activeElement).toBe(first)
225
+ ;(document.activeElement as HTMLElement).blur()
226
+ expect(pressTab(true).defaultPrevented).toBe(true)
227
+ expect(document.activeElement).toBe(last)
228
+ })
229
+
230
+ it('leaves Tab alone in a menu portaled out of the dialog', async () => {
231
+ await mount(<Dialog open><button>inside</button></Dialog>)
232
+ const menuItem = document.createElement('button')
233
+ document.body.appendChild(menuItem)
234
+ menuItem.focus()
235
+ expect(pressTab().defaultPrevented).toBe(false)
236
+ expect(document.activeElement).toBe(menuItem)
237
+ })
238
+
239
+ it('lets the dialog underneath recover Tab while the one above it animates out', async () => {
240
+ function Stack({ top }: { top: boolean }) {
241
+ return (
242
+ <>
243
+ <Dialog open><button>under</button></Dialog>
244
+ <DialogPortal open={top} onClose={() => {}} ariaLabel="top"><button>over</button></DialogPortal>
245
+ </>
246
+ )
247
+ }
248
+ const { render } = await mount(<Stack top />)
249
+ await render(<Stack top={false} />)
250
+ // The closing dialog is still mounted (inert) for its exit animation.
251
+ expect(document.querySelectorAll('[role="dialog"]').length).toBe(2)
252
+ ;(document.activeElement as HTMLElement | null)?.blur()
253
+ expect(pressTab().defaultPrevented).toBe(true)
254
+ expect(document.activeElement?.textContent).toBe('under')
255
+ })
256
+
257
+ it('wraps past controls that cannot take focus, like those in a disabled fieldset', async () => {
258
+ await mount(
259
+ <Dialog open>
260
+ <button>first</button>
261
+ <button>last</button>
262
+ <fieldset disabled><button>locked</button></fieldset>
263
+ </Dialog>,
264
+ )
265
+ const [first, last] = Array.from(dialog()!.querySelectorAll('button'))
266
+ const [startGuard] = guards()
267
+ first.focus()
268
+ startGuard.focus()
269
+ expect(document.activeElement).toBe(last)
270
+ })
271
+
272
+ it('falls back to the panel when nothing in it will take focus', async () => {
273
+ await mount(<Dialog open><button>refuses</button></Dialog>)
274
+ const button = dialog()!.querySelector('button')!
275
+ vi.spyOn(button, 'focus').mockImplementation(() => {})
276
+ guards()[1].focus()
277
+ expect(document.activeElement).toBe(dialog())
278
+ })
279
+ })