fix(ux): resolve tickets #55 #56 — resource modal stability and success feedback

#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:
2026-04-03 15:43:12 +02:00
parent 0d0707264d
commit 65db330a4d
3 changed files with 39 additions and 10 deletions
@@ -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 });
}
+16 -7
View File
@@ -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]);
}