Merge feature/tickets-window into develop
Lot C du sprint UI rework : fenêtre dédiée de gestion des tickets (#17). Clôt le sprint UI rework (#16, #18, #19, #17 intégrés). Tests verts (tsc clean, suite complète 530/530). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -12,10 +12,11 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { TicketLinkKind } from "@/domain";
|
import type { TicketLinkKind } from "@/domain";
|
||||||
import { Button, Input, Spinner, cn } from "@/shared";
|
import { Button, FloatingWindow, Input, Spinner, cn } from "@/shared";
|
||||||
import { useTicketDetail } from "./useTicketDetail";
|
import { useTicketDetail } from "./useTicketDetail";
|
||||||
import { useProjectAgents } from "./useProjectAgents";
|
import { useProjectAgents } from "./useProjectAgents";
|
||||||
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||||
|
import { TicketPicker } from "./TicketPicker";
|
||||||
import {
|
import {
|
||||||
PriorityBadge,
|
PriorityBadge,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -98,7 +99,6 @@ export function TicketDetail({
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [carnet, setCarnet] = useState("");
|
const [carnet, setCarnet] = useState("");
|
||||||
const [linkTarget, setLinkTarget] = useState("");
|
|
||||||
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
|
const [linkKind, setLinkKind] = useState<TicketLinkKind>("relatesTo");
|
||||||
const [assignPick, setAssignPick] = useState("");
|
const [assignPick, setAssignPick] = useState("");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@ -106,6 +106,11 @@ export function TicketDetail({
|
|||||||
const [confirmClose, setConfirmClose] = useState(false);
|
const [confirmClose, setConfirmClose] = useState(false);
|
||||||
// Confirmation shown before deleting the ticket (#6).
|
// Confirmation shown before deleting the ticket (#6).
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
// Add-link TicketPicker open state (#17): replaces the manual `#id` input.
|
||||||
|
const [showLinkPicker, setShowLinkPicker] = useState(false);
|
||||||
|
// AI writing-assistant window open state (#17): opened from the bottom-right
|
||||||
|
// floating button so the assistant is an obvious, first-class affordance.
|
||||||
|
const [showAssistant, setShowAssistant] = useState(false);
|
||||||
|
|
||||||
// The ticket was deleted (by this view or another actor): close the surface.
|
// The ticket was deleted (by this view or another actor): close the surface.
|
||||||
// The removal from lists flows from the same `issueDeleted` event (#6).
|
// The removal from lists flows from the same `issueDeleted` event (#6).
|
||||||
@ -187,14 +192,14 @@ export function TicketDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FloatingWindow
|
||||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
open
|
||||||
role="dialog"
|
title={`ticket ${ticketRef}`}
|
||||||
aria-modal="true"
|
size="xl"
|
||||||
aria-label={`ticket ${ticketRef}`}
|
onClose={requestClose}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Toolbar (badges + ticket-level actions) ── */}
|
||||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<TicketRef ticketRef={ticketRef} />
|
<TicketRef ticketRef={ticketRef} />
|
||||||
{t && <StatusBadge status={t.status} />}
|
{t && <StatusBadge status={t.status} />}
|
||||||
@ -223,16 +228,13 @@ export function TicketDetail({
|
|||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={requestClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
{vm.conflict && (
|
{vm.conflict && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning"
|
className="border-b border-warning/40 bg-warning/10 px-1 py-2 text-sm text-warning"
|
||||||
>
|
>
|
||||||
This ticket was modified elsewhere and reloaded. Re-apply your change.
|
This ticket was modified elsewhere and reloaded. Re-apply your change.
|
||||||
</p>
|
</p>
|
||||||
@ -240,23 +242,23 @@ export function TicketDetail({
|
|||||||
{vm.error && !vm.conflict && (
|
{vm.error && !vm.conflict && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
className="shrink-0 border-b border-danger/40 bg-danger/10 px-5 py-2 text-sm text-danger"
|
className="border-b border-danger/40 bg-danger/10 px-1 py-2 text-sm text-danger"
|
||||||
>
|
>
|
||||||
{vm.error}
|
{vm.error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vm.busy && !t ? (
|
{vm.busy && !t ? (
|
||||||
<div className="flex flex-1 items-center gap-2 p-5 text-sm text-muted">
|
<div className="flex items-center gap-2 py-5 text-sm text-muted">
|
||||||
<Spinner size={14} />
|
<Spinner size={14} />
|
||||||
<span>Loading ticket…</span>
|
<span>Loading ticket…</span>
|
||||||
</div>
|
</div>
|
||||||
) : !t ? (
|
) : !t ? (
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
<div className="flex items-center justify-center py-10 text-sm text-muted">
|
||||||
Ticket not found.
|
Ticket not found.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="-mx-4">
|
||||||
{/* ── Fields (F3) ── */}
|
{/* ── Fields (F3) ── */}
|
||||||
<Section title="Details">
|
<Section title="Details">
|
||||||
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
||||||
@ -421,6 +423,8 @@ export function TicketDetail({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{/* Add a link via the TicketPicker popup (#17): the kind is chosen
|
||||||
|
here, the target ticket is selected in the popup (no manual #id). */}
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
aria-label="link kind"
|
aria-label="link kind"
|
||||||
@ -435,23 +439,11 @@ export function TicketDetail({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Input
|
|
||||||
aria-label="link target ref"
|
|
||||||
placeholder="#id"
|
|
||||||
className="w-24"
|
|
||||||
value={linkTarget}
|
|
||||||
onChange={(e) => setLinkTarget(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!linkTarget.trim() || vm.busy}
|
aria-label="add link"
|
||||||
onClick={async () => {
|
disabled={vm.busy}
|
||||||
const target = linkTarget.trim().startsWith("#")
|
onClick={() => setShowLinkPicker(true)}
|
||||||
? linkTarget.trim()
|
|
||||||
: `#${linkTarget.trim()}`;
|
|
||||||
const ok = await vm.link(target, linkKind);
|
|
||||||
if (ok) setLinkTarget("");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Add link
|
Add link
|
||||||
</Button>
|
</Button>
|
||||||
@ -518,16 +510,48 @@ export function TicketDetail({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── AI assistant chat (#8, F2) ── */}
|
{/* ── Add-link picker (#17) — nested (token 60) over this window ── */}
|
||||||
<Section
|
<TicketPicker
|
||||||
title="Assistant IA"
|
open={showLinkPicker}
|
||||||
hint="Conversez avec un agent IA pour faire évoluer ce ticket. L'agent lit le projet en lecture seule et ne peut éditer que ce ticket."
|
projectId={projectId}
|
||||||
>
|
title="Lier un ticket"
|
||||||
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
|
selectionMode="single"
|
||||||
</Section>
|
excludeRefs={[ticketRef, ...t.links.map((l) => l.targetRef)]}
|
||||||
|
onSelect={(result) => {
|
||||||
|
const picked = Array.isArray(result) ? result[0] : result;
|
||||||
|
if (picked) void vm.link(picked.ref, linkKind);
|
||||||
|
setShowLinkPicker(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowLinkPicker(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Floating "Assistant IA" button (#17), bottom-right of the window.
|
||||||
|
Made prominent per the explicit user request; opens the ticket-writing
|
||||||
|
AI assistant in a nested window. Sticks to the window's bottom edge. ── */}
|
||||||
|
{t && (
|
||||||
|
<div className="pointer-events-none sticky bottom-0 z-10 -mx-4 -mb-4 flex justify-end px-4 pb-4 pt-2">
|
||||||
|
<Button
|
||||||
|
aria-label="ouvrir l'assistant IA"
|
||||||
|
className="pointer-events-auto shadow-lg"
|
||||||
|
onClick={() => setShowAssistant(true)}
|
||||||
|
>
|
||||||
|
🤖 Assistant IA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI writing assistant, in a nested window (token 60) above this one. */}
|
||||||
|
<FloatingWindow
|
||||||
|
open={showAssistant}
|
||||||
|
title={`Assistant IA — ${ticketRef}`}
|
||||||
|
size="lg"
|
||||||
|
onClose={() => setShowAssistant(false)}
|
||||||
|
>
|
||||||
|
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
|
||||||
|
</FloatingWindow>
|
||||||
|
|
||||||
{/* ── Unsaved-changes confirmation on close (#9) ── */}
|
{/* ── Unsaved-changes confirmation on close (#9) ── */}
|
||||||
{confirmClose && (
|
{confirmClose && (
|
||||||
<div
|
<div
|
||||||
@ -623,6 +647,6 @@ export function TicketDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FloatingWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -645,47 +645,96 @@ describe("TicketsView", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
||||||
expect(within(detail).getByText("Assistant IA")).toBeTruthy();
|
|
||||||
|
|
||||||
const profileSelect = await within(detail).findByLabelText(
|
// The assistant is opened from the prominent bottom-right floating button;
|
||||||
|
// it is not inline anymore (#17).
|
||||||
|
expect(within(detail).queryByLabelText("profil de l'assistant")).toBeNull();
|
||||||
|
fireEvent.click(within(detail).getByLabelText("ouvrir l'assistant IA"));
|
||||||
|
|
||||||
|
// It opens in its own nested window.
|
||||||
|
const assistant = await screen.findByRole("dialog", {
|
||||||
|
name: `Assistant IA — ${t.ref}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileSelect = await within(assistant).findByLabelText(
|
||||||
"profil de l'assistant",
|
"profil de l'assistant",
|
||||||
);
|
);
|
||||||
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
|
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
|
||||||
fireEvent.click(within(detail).getByText("Ouvrir la conversation"));
|
fireEvent.click(within(assistant).getByText("Ouvrir la conversation"));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await within(detail).findByLabelText("message à l'assistant"),
|
await within(assistant).findByLabelText("message à l'assistant"),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.change(within(detail).getByLabelText("message à l'assistant"), {
|
fireEvent.change(within(assistant).getByLabelText("message à l'assistant"), {
|
||||||
target: { value: "Crée un plan de correction" },
|
target: { value: "Crée un plan de correction" },
|
||||||
});
|
});
|
||||||
fireEvent.click(within(detail).getByText("Envoyer"));
|
fireEvent.click(within(assistant).getByText("Envoyer"));
|
||||||
|
|
||||||
expect(await within(detail).findByText("Crée un plan de correction")).toBeTruthy();
|
|
||||||
expect(
|
expect(
|
||||||
await within(detail).findByText(
|
await within(assistant).findByText("Crée un plan de correction"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
await within(assistant).findByText(
|
||||||
"Assistant: reçu « Crée un plan de correction ».",
|
"Assistant: reçu « Crée un plan de correction ».",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(detail).getByText("Fermer la conversation"));
|
fireEvent.click(within(assistant).getByText("Fermer la conversation"));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
within(detail).queryByLabelText("message à l'assistant"),
|
within(assistant).queryByLabelText("message à l'assistant"),
|
||||||
).toBeNull(),
|
).toBeNull(),
|
||||||
);
|
);
|
||||||
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
within(detail).queryByText("Crée un plan de correction"),
|
within(assistant).queryByText("Crée un plan de correction"),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
within(detail).queryByText("Assistant: reçu « Crée un plan de correction »."),
|
within(assistant).queryByText(
|
||||||
|
"Assistant: reçu « Crée un plan de correction ».",
|
||||||
|
),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds a link via the TicketPicker popup instead of a manual #id (#17)", async () => {
|
||||||
|
const a = await ticket.create(PROJECT_ID, { title: "Source ticket" });
|
||||||
|
const b = await ticket.create(PROJECT_ID, { title: "Target ticket" });
|
||||||
|
const gateways = { ticket, system, agent } as unknown as Gateways;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<TicketDetail
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
ticketRef={a.ref}
|
||||||
|
onClose={() => {}}
|
||||||
|
onOpenRef={() => {}}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const detail = await screen.findByRole("dialog", { name: `ticket ${a.ref}` });
|
||||||
|
// The old manual "#id" input is gone.
|
||||||
|
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
|
||||||
|
|
||||||
|
// Choose the link kind, then open the picker and select the target ticket.
|
||||||
|
fireEvent.change(within(detail).getByLabelText("link kind"), {
|
||||||
|
target: { value: "blocks" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(detail).getByLabelText("add link"));
|
||||||
|
|
||||||
|
// The picker excludes the ticket itself; the target is offered.
|
||||||
|
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
|
||||||
|
fireEvent.click(await screen.findByLabelText(`select ticket ${b.ref}`));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const fresh = await ticket.read(PROJECT_ID, a.ref);
|
||||||
|
expect(fresh.links).toEqual([{ targetRef: b.ref, kind: "blocks" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SprintManager (#11)", () => {
|
describe("SprintManager (#11)", () => {
|
||||||
|
|||||||
@ -7,6 +7,41 @@ import { MenuBar } from "./MenuBar";
|
|||||||
import { zIndex } from "./zIndex";
|
import { zIndex } from "./zIndex";
|
||||||
|
|
||||||
describe("FloatingWindow (#16, G1)", () => {
|
describe("FloatingWindow (#16, G1)", () => {
|
||||||
|
it("Escape closes only the topmost window when stacked (#17)", () => {
|
||||||
|
const onCloseOuter = vi.fn();
|
||||||
|
const onCloseInner = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<>
|
||||||
|
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
|
||||||
|
<p>outer body</p>
|
||||||
|
</FloatingWindow>
|
||||||
|
<FloatingWindow open title="Inner" onClose={onCloseInner}>
|
||||||
|
<p>inner body</p>
|
||||||
|
</FloatingWindow>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The inner (last-mounted) window is topmost: Escape closes only it.
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onCloseInner).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCloseOuter).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Unmount the inner window; the outer becomes topmost and now handles Escape.
|
||||||
|
rerender(
|
||||||
|
<>
|
||||||
|
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
|
||||||
|
<p>outer body</p>
|
||||||
|
</FloatingWindow>
|
||||||
|
<FloatingWindow open={false} title="Inner" onClose={onCloseInner}>
|
||||||
|
<p>inner body</p>
|
||||||
|
</FloatingWindow>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onCloseOuter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCloseInner).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders nothing when closed", () => {
|
it("renders nothing when closed", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<FloatingWindow open={false} title="Panel" onClose={vi.fn()}>
|
<FloatingWindow open={false} title="Panel" onClose={vi.fn()}>
|
||||||
|
|||||||
@ -34,6 +34,13 @@ const SIZE_CLASS: Record<FloatingWindowSize, string> = {
|
|||||||
const FOCUSABLE =
|
const FOCUSABLE =
|
||||||
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stack of currently-mounted windows (most-recent last). Only the topmost window
|
||||||
|
* reacts to Escape and traps Tab, so a window opened over another (e.g. a ticket
|
||||||
|
* detail over the tickets list) doesn't close/steal from the one beneath it.
|
||||||
|
*/
|
||||||
|
const windowStack: object[] = [];
|
||||||
|
|
||||||
export interface FloatingWindowProps {
|
export interface FloatingWindowProps {
|
||||||
/** Whether the window is shown. Rendered as `null` when closed. */
|
/** Whether the window is shown. Rendered as `null` when closed. */
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -76,11 +83,17 @@ function FloatingWindowBody({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
const node = dialogRef.current;
|
const node = dialogRef.current;
|
||||||
|
// Register on the window stack so only the topmost handles keys.
|
||||||
|
const token = {};
|
||||||
|
windowStack.push(token);
|
||||||
|
const isTopmost = () => windowStack[windowStack.length - 1] === token;
|
||||||
// Move focus into the window (first focusable, else the dialog itself).
|
// Move focus into the window (first focusable, else the dialog itself).
|
||||||
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
|
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
|
||||||
(first ?? node)?.focus();
|
(first ?? node)?.focus();
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
// Ignore keys while a later window is stacked on top of this one.
|
||||||
|
if (!isTopmost()) return;
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
@ -111,6 +124,8 @@ function FloatingWindowBody({
|
|||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
const i = windowStack.indexOf(token);
|
||||||
|
if (i >= 0) windowStack.splice(i, 1);
|
||||||
previouslyFocused?.focus?.();
|
previouslyFocused?.focus?.();
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|||||||
Reference in New Issue
Block a user