#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 { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||
|
||||
type ModalState =
|
||||
| { type: "closed" }
|
||||
@@ -158,6 +159,7 @@ export function ResourcesClient() {
|
||||
const [departedFilter, setDepartedFilter] = useState<BooleanFilter>(DEFAULT_BOOLEAN_FILTER);
|
||||
const [modal, setModal] = useState<ModalState>({ type: "closed" });
|
||||
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
|
||||
const [successToast, setSuccessToast] = useState<string | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
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" && (
|
||||
<ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />
|
||||
)}
|
||||
@@ -1461,6 +1469,12 @@ export function ResourcesClient() {
|
||||
onCancel={() => setConfirm({ type: "closed" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SuccessToast
|
||||
show={successToast !== null}
|
||||
message={successToast ?? ""}
|
||||
onDone={() => setSuccessToast(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ interface ResourceModalProps {
|
||||
mode: "create" | "edit";
|
||||
resource?: Resource;
|
||||
onClose: () => void;
|
||||
onSuccess?: (displayName: string) => void;
|
||||
}
|
||||
|
||||
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>(() =>
|
||||
resource ? resourceToFormState(resource) : defaultFormState(),
|
||||
);
|
||||
@@ -324,7 +325,12 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
|
||||
try {
|
||||
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) {
|
||||
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;
|
||||
const el = ref.current;
|
||||
|
||||
const focusable = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
// Focus first element when modal opens
|
||||
first?.focus();
|
||||
// Defer initial focus to the next animation frame so the browser layout is
|
||||
// complete before we attempt to focus — prevents intermittent failures when
|
||||
// the modal DOM has just been mounted.
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const focusable = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||
focusable[0]?.focus();
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
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; }
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
||||
} else {
|
||||
@@ -26,6 +32,9 @@ export function useFocusTrap(ref: React.RefObject<HTMLElement | null>, isOpen: b
|
||||
}
|
||||
|
||||
el.addEventListener("keydown", handleKeyDown);
|
||||
return () => el.removeEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
el.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, ref]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user