feat(web): retrofit drawers + CreateTopologyWizard with ESC/focus-trap
ArtifactDrawer, SessionDrawer, CreateTopologyWizard all now: - close on ESC - trap Tab/Shift+Tab focus within the panel - lock body scroll while open - restore prior focus on unmount Uses the new useEscapeKey + useFocusTrap hooks. No visual changes; the bespoke CSS shells (ctw-*, inline drawer styling) are preserved.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { X, Download, AlertTriangle } from 'lucide-react';
|
import { X, Download, AlertTriangle } from 'lucide-react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
|
import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||||
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||||
|
|
||||||
interface ArtifactDrawerProps {
|
interface ArtifactDrawerProps {
|
||||||
decky: string;
|
decky: string;
|
||||||
@@ -32,6 +34,15 @@ const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ArtifactDrawer: React.FC<ArtifactDrawerProps> = ({ decky, storedAs, fields, onClose }) => {
|
const ArtifactDrawer: React.FC<ArtifactDrawerProps> = ({ decky, storedAs, fields, onClose }) => {
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEscapeKey(onClose, true);
|
||||||
|
useFocusTrap(panelRef, true);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const meta = decodeMeta(fields);
|
const meta = decodeMeta(fields);
|
||||||
@@ -80,6 +91,9 @@ const ArtifactDrawer: React.FC<ArtifactDrawerProps> = ({ decky, storedAs, fields
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: 'min(620px, 100%)', height: '100%',
|
width: 'min(620px, 100%)', height: '100%',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { X, AlertTriangle } from 'lucide-react';
|
import { X, AlertTriangle } from 'lucide-react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
|
import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||||
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||||
// @ts-expect-error -- ships without type defs; 3.x CJS build is used directly
|
// @ts-expect-error -- ships without type defs; 3.x CJS build is used directly
|
||||||
import * as AsciinemaPlayer from 'asciinema-player';
|
import * as AsciinemaPlayer from 'asciinema-player';
|
||||||
import 'asciinema-player/dist/bundle/asciinema-player.css';
|
import 'asciinema-player/dist/bundle/asciinema-player.css';
|
||||||
@@ -46,6 +48,15 @@ function buildCastBlob(header: Record<string, any>, events: [number, string, str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SessionDrawer: React.FC<SessionDrawerProps> = ({ decky, sid, fields, onClose }) => {
|
const SessionDrawer: React.FC<SessionDrawerProps> = ({ decky, sid, fields, onClose }) => {
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEscapeKey(onClose, true);
|
||||||
|
useFocusTrap(panelRef, true);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [header, setHeader] = useState<Record<string, any> | null>(null);
|
const [header, setHeader] = useState<Record<string, any> | null>(null);
|
||||||
const [events, setEvents] = useState<[number, string, string][]>([]);
|
const [events, setEvents] = useState<[number, string, string][]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -145,6 +156,9 @@ const SessionDrawer: React.FC<SessionDrawerProps> = ({ decky, sid, fields, onClo
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: 'min(920px, 100%)', height: '100%',
|
width: 'min(920px, 100%)', height: '100%',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { X, Server, Cpu, FileText, Sparkles, Check } from 'lucide-react';
|
import { X, Server, Cpu, FileText, Sparkles, Check } from 'lucide-react';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
|
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||||
|
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||||
import './CreateTopologyWizard.css';
|
import './CreateTopologyWizard.css';
|
||||||
|
|
||||||
/* Shape of GET /swarm/hosts rows (mirrors SwarmHostView). */
|
/* Shape of GET /swarm/hosts rows (mirrors SwarmHostView). */
|
||||||
@@ -36,6 +38,17 @@ interface Props {
|
|||||||
const LOCAL_CARD_ID = '__local__';
|
const LOCAL_CARD_ID = '__local__';
|
||||||
|
|
||||||
const CreateTopologyWizard: React.FC<Props> = ({ open, onClose, onCreated }) => {
|
const CreateTopologyWizard: React.FC<Props> = ({ open, onClose, onCreated }) => {
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEscapeKey(onClose, open);
|
||||||
|
useFocusTrap(panelRef, open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const [step, setStep] = useState<0 | 1>(0);
|
const [step, setStep] = useState<0 | 1>(0);
|
||||||
const [targetId, setTargetId] = useState<string | null>(null); // LOCAL_CARD_ID or host uuid
|
const [targetId, setTargetId] = useState<string | null>(null); // LOCAL_CARD_ID or host uuid
|
||||||
const [kind, setKind] = useState<Kind | null>(null);
|
const [kind, setKind] = useState<Kind | null>(null);
|
||||||
@@ -228,7 +241,7 @@ const CreateTopologyWizard: React.FC<Props> = ({ open, onClose, onCreated }) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ctw-backdrop" onClick={onClose}>
|
<div className="ctw-backdrop" onClick={onClose}>
|
||||||
<div className="ctw-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="ctw-modal" ref={panelRef} role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="ctw-head">
|
<div className="ctw-head">
|
||||||
<h3>
|
<h3>
|
||||||
<Sparkles size={14} style={{ marginRight: 8 }} /> NEW TOPOLOGY
|
<Sparkles size={14} style={{ marginRight: 8 }} /> NEW TOPOLOGY
|
||||||
|
|||||||
Reference in New Issue
Block a user