#55: Add SuccessToast after new resource is created. ResourceModal gains an optional onSuccess(displayName) prop; ResourcesClient wires it to a toast that auto-dismisses after 2.5 s. #56: Fix useFocusTrap stale-closure bug. Focusable elements are now queried dynamically inside handleKeyDown (not captured once at mount), so Tab navigation stays correct as the form re-renders. Initial focus is deferred via requestAnimationFrame so the browser layout is stable before focus() fires. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -35,6 +35,7 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
|||||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||||
|
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||||
|
|
||||||
type ModalState =
|
type ModalState =
|
||||||
| { type: "closed" }
|
| { type: "closed" }
|
||||||
@@ -158,6 +159,7 @@ export function ResourcesClient() {
|
|||||||
const [departedFilter, setDepartedFilter] = useState<BooleanFilter>(DEFAULT_BOOLEAN_FILTER);
|
const [departedFilter, setDepartedFilter] = useState<BooleanFilter>(DEFAULT_BOOLEAN_FILTER);
|
||||||
const [modal, setModal] = useState<ModalState>({ type: "closed" });
|
const [modal, setModal] = useState<ModalState>({ type: "closed" });
|
||||||
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
|
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
|
||||||
|
const [successToast, setSuccessToast] = useState<string | null>(null);
|
||||||
|
|
||||||
const selection = useSelection();
|
const selection = useSelection();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -1427,7 +1429,13 @@ export function ResourcesClient() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{modal.type === "create" && <ResourceModal mode="create" onClose={closeModal} />}
|
{modal.type === "create" && (
|
||||||
|
<ResourceModal
|
||||||
|
mode="create"
|
||||||
|
onClose={closeModal}
|
||||||
|
onSuccess={(name) => setSuccessToast(`Resource "${name}" created successfully.`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{modal.type === "edit" && (
|
{modal.type === "edit" && (
|
||||||
<ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />
|
<ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />
|
||||||
)}
|
)}
|
||||||
@@ -1461,6 +1469,12 @@ export function ResourcesClient() {
|
|||||||
onCancel={() => setConfirm({ type: "closed" })}
|
onCancel={() => setConfirm({ type: "closed" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SuccessToast
|
||||||
|
show={successToast !== null}
|
||||||
|
message={successToast ?? ""}
|
||||||
|
onDone={() => setSuccessToast(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ interface ResourceModalProps {
|
|||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
resource?: Resource;
|
resource?: Resource;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: (displayName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT_CLASS =
|
const INPUT_CLASS =
|
||||||
@@ -189,7 +190,7 @@ function Spinner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceModalProps) {
|
||||||
const [form, setForm] = useState<FormState>(() =>
|
const [form, setForm] = useState<FormState>(() =>
|
||||||
resource ? resourceToFormState(resource) : defaultFormState(),
|
resource ? resourceToFormState(resource) : defaultFormState(),
|
||||||
);
|
);
|
||||||
@@ -324,7 +325,12 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
await createMutation.mutateAsync(payload);
|
const created = await createMutation.mutateAsync(payload);
|
||||||
|
void utils.resource.directory.invalidate();
|
||||||
|
void utils.resource.listStaff.invalidate();
|
||||||
|
onSuccess?.(created.displayName);
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
} else if (resource) {
|
} else if (resource) {
|
||||||
await updateMutation.mutateAsync({ id: resource.id, data: payload });
|
await updateMutation.mutateAsync({ id: resource.id, data: payload });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,22 @@ export function useFocusTrap(ref: React.RefObject<HTMLElement | null>, isOpen: b
|
|||||||
if (!isOpen || !ref.current) return;
|
if (!isOpen || !ref.current) return;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
|
|
||||||
const focusable = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
// Defer initial focus to the next animation frame so the browser layout is
|
||||||
const first = focusable[0];
|
// complete before we attempt to focus — prevents intermittent failures when
|
||||||
const last = focusable[focusable.length - 1];
|
// the modal DOM has just been mounted.
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
// Focus first element when modal opens
|
const focusable = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||||
first?.focus();
|
focusable[0]?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key !== "Tab") return;
|
if (e.key !== "Tab") return;
|
||||||
|
// Re-query on every keydown so the list is always current — form state can
|
||||||
|
// toggle disabled attributes and add/remove sections during a session.
|
||||||
|
const focusable = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||||
if (focusable.length === 0) { e.preventDefault(); return; }
|
if (focusable.length === 0) { e.preventDefault(); return; }
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
||||||
} else {
|
} else {
|
||||||
@@ -26,6 +32,9 @@ export function useFocusTrap(ref: React.RefObject<HTMLElement | null>, isOpen: b
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.addEventListener("keydown", handleKeyDown);
|
el.addEventListener("keydown", handleKeyDown);
|
||||||
return () => el.removeEventListener("keydown", handleKeyDown);
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
el.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
}, [isOpen, ref]);
|
}, [isOpen, ref]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user