@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.
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +4 -3
- package/src/components/gitops/RollbackDialog.tsx +4 -3
- package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
- package/src/components/gitops/health-provenance.test.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
- package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
- package/src/components/resources/KueueAdmissionSection.tsx +140 -51
- package/src/components/resources/ResourcesView.tsx +4 -0
- package/src/components/resources/printer-columns.test.ts +1 -1
- package/src/components/resources/renderers/JobRenderer.tsx +5 -1
- package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
- package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils-kueue.ts +14 -0
- package/src/components/resources/resource-utils-ray.ts +14 -4
- package/src/components/shared/CreateResourceDialog.tsx +6 -2
- package/src/components/shared/ResourceActionsBar.tsx +4 -2
- package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
- package/src/components/shared/SetImageDialog.tsx +4 -2
- package/src/components/shared/revision-role-badges.test.ts +1 -1
- package/src/components/trace/reachGraphModel.test.ts +1 -0
- package/src/components/trace/reachInspector.test.ts +4 -9
- package/src/components/ui/ConfirmDialog.tsx +5 -3
- package/src/components/ui/DialogPortal.test.tsx +279 -0
- package/src/components/ui/DialogPortal.tsx +147 -9
- package/src/components/ui/YamlReview.tsx +4 -1
- package/src/components/ui/drawer-components.test.tsx +14 -1
- 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 =
|
|
319
|
-
|
|
320
|
-
|
|
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 && <
|
|
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) && <
|
|
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
|
|
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
|
|
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
|
|
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" }
|
|
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
|
+
})
|