feat(FN-2888): unify task move button interactions

- Replace split move control with a single move button that supports primary action plus arrow-zone menu toggling
- Add keyboard support for move options via ArrowDown to open and Escape to close and refocus the trigger
- Update task detail styling for the unified move button and arrow affordance states
- Refresh TaskDetailModal and TaskForm tests to cover the new move control behavior and preset selection query
This commit is contained in:
Fusion
2026-04-28 13:53:46 -07:00
committed by gsxdsm
parent d8eb39dab7
commit 14d35b443d
4 changed files with 147 additions and 99 deletions

View File

@@ -648,42 +648,31 @@
align-items: center;
}
.detail-move-split-btn {
.detail-move-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.detail-move-btn__label {
flex: 1;
}
.detail-move-btn__arrow {
display: flex;
align-items: stretch;
border-radius: var(--radius-md);
}
.detail-move-split-btn__main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.detail-move-split-btn__main:focus-visible,
.detail-move-split-btn__chevron:focus-visible {
box-shadow: var(--focus-ring-strong);
outline: none;
}
.detail-move-split-btn__chevron {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
min-width: calc(var(--space-xs) + var(--space-sm) + var(--space-md));
padding: 0;
}
.detail-move-split-btn__divider {
width: 1px;
height: calc(var(--space-sm) + var(--space-xs));
align-self: center;
background: var(--border);
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: var(--space-xs);
margin-right: calc(-1 * var(--space-xs));
margin-block: calc(-1 * var(--space-xs));
border-radius: 0 var(--radius-md) var(--radius-md) 0;
cursor: pointer;
}
@media (hover: hover) {
.detail-move-split-btn__chevron:hover {
background: var(--cta-bg-hover, var(--cta-bg));
.detail-move-btn:hover .detail-move-btn__arrow {
background: color-mix(in srgb, var(--text) 12%, transparent);
}
}
@@ -708,10 +697,6 @@
right: 0;
}
.detail-move-split-btn__menu {
right: 0;
}
.detail-actions-menu-item,
.detail-move-menu-item {
display: block;
@@ -739,8 +724,8 @@
}
@media (max-width: 768px) {
.detail-move-split-btn__chevron {
min-width: calc(var(--space-md) + var(--space-sm));
.detail-move-btn__arrow {
padding: var(--space-xs);
}
/* Full-screen sheet on mobile. Desktop `min-width: 480px` would otherwise

View File

@@ -377,6 +377,7 @@ export function TaskDetailModal({
const [showMoveMenu, setShowMoveMenu] = useState(false);
const [showActionsMenu, setShowActionsMenu] = useState(false);
const moveMenuRef = useRef<HTMLDivElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:task-detail-modal-size");
@@ -1279,6 +1280,71 @@ export function TaskDetailModal({
const primaryMoveTransition = moveTransitions[0];
const secondaryMoveTransitions = moveTransitions.slice(1);
const hasSecondaryMoveOptions = secondaryMoveTransitions.length > 0;
const closeMoveMenuAndFocusTrigger = useCallback(() => {
setShowMoveMenu(false);
moveButtonRef.current?.focus();
}, []);
const handleMoveButtonClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
if (!hasSecondaryMoveOptions) {
if (primaryMoveTransition) {
void handleMoveMenuItemClick(primaryMoveTransition);
}
return;
}
const arrowZone = event.currentTarget.querySelector<HTMLSpanElement>(".detail-move-btn__arrow");
const clickedArrow = Boolean(
(event.target instanceof Element && event.target.closest(".detail-move-btn__arrow")) ||
(arrowZone && event.clientX > 0 && event.clientX >= arrowZone.getBoundingClientRect().left),
);
if (clickedArrow) {
setShowMoveMenu((prev) => !prev);
setShowActionsMenu(false);
return;
}
if (primaryMoveTransition) {
void handleMoveMenuItemClick(primaryMoveTransition);
}
}, [hasSecondaryMoveOptions, primaryMoveTransition, handleMoveMenuItemClick]);
const handleMoveButtonKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!hasSecondaryMoveOptions) {
return;
}
const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown");
if (!shouldOpenMenu) {
return;
}
event.preventDefault();
setShowMoveMenu(true);
setShowActionsMenu(false);
}, [hasSecondaryMoveOptions]);
const handleMoveMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
closeMoveMenuAndFocusTrigger();
}, [closeMoveMenuAndFocusTrigger]);
useEffect(() => {
if (!showMoveMenu) {
return;
}
const firstMenuItem = moveMenuRef.current?.querySelector<HTMLButtonElement>(".detail-move-menu-item");
firstMenuItem?.focus();
}, [showMoveMenu]);
const prAutomationStatusLabels: Record<string, string> = {
"creating-pr": "Creating PR…",
"awaiting-pr-checks": "Awaiting PR checks",
@@ -2171,39 +2237,35 @@ export function TaskDetailModal({
<div className="detail-move-dropdown" ref={moveMenuRef}>
{task.column === "in-review" ? (
<div className="detail-move-actions-in-review">
<div className="detail-move-split-btn">
<div>
<button
className="btn btn-primary btn-sm detail-move-split-btn__main"
onClick={() => primaryMoveTransition && handleMoveMenuItemClick(primaryMoveTransition)}
ref={moveButtonRef}
className="btn btn-primary btn-sm detail-move-btn"
onClick={handleMoveButtonClick}
onKeyDown={handleMoveButtonKeyDown}
disabled={!primaryMoveTransition}
aria-label={primaryMoveTransition ? `Move to ${COLUMN_LABELS[primaryMoveTransition]}` : undefined}
aria-haspopup={hasSecondaryMoveOptions ? "menu" : undefined}
aria-expanded={hasSecondaryMoveOptions ? showMoveMenu : undefined}
>
Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
</button>
{hasSecondaryMoveOptions && (
<>
<span className="detail-move-split-btn__divider" aria-hidden="true" />
<button
className="btn btn-primary btn-sm detail-move-split-btn__chevron"
onClick={() => {
setShowMoveMenu((prev) => !prev);
setShowActionsMenu(false);
}}
aria-label="More move options"
aria-haspopup="menu"
aria-expanded={showMoveMenu}
>
<span className="detail-move-btn__label">
Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
</span>
{hasSecondaryMoveOptions && (
<span className="detail-move-btn__arrow" aria-hidden="true">
<ChevronDown size={12} />
</button>
</>
)}
</span>
)}
</button>
{showMoveMenu && hasSecondaryMoveOptions && (
<div className="detail-move-menu detail-move-split-btn__menu" role="menu">
<div className="detail-move-menu" role="menu" onKeyDown={handleMoveMenuKeyDown}>
{secondaryMoveTransitions.map((col) => (
<button
key={col}
className="detail-move-menu-item"
role="menuitem"
onClick={() => handleMoveMenuItemClick(col)}
onKeyDown={handleMoveMenuKeyDown}
>
{col === "in-progress" ? "Back to In Progress" : `Move to ${COLUMN_LABELS[col]}`}
</button>
@@ -2222,39 +2284,35 @@ export function TaskDetailModal({
)}
</div>
) : (
<div className="detail-move-split-btn">
<div>
<button
className="btn btn-primary btn-sm detail-move-split-btn__main"
onClick={() => primaryMoveTransition && handleMoveMenuItemClick(primaryMoveTransition)}
ref={moveButtonRef}
className="btn btn-primary btn-sm detail-move-btn"
onClick={handleMoveButtonClick}
onKeyDown={handleMoveButtonKeyDown}
disabled={!primaryMoveTransition}
aria-label={primaryMoveTransition ? `Move to ${COLUMN_LABELS[primaryMoveTransition]}` : undefined}
aria-haspopup={hasSecondaryMoveOptions ? "menu" : undefined}
aria-expanded={hasSecondaryMoveOptions ? showMoveMenu : undefined}
>
Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
</button>
{hasSecondaryMoveOptions && (
<>
<span className="detail-move-split-btn__divider" aria-hidden="true" />
<button
className="btn btn-primary btn-sm detail-move-split-btn__chevron"
onClick={() => {
setShowMoveMenu((prev) => !prev);
setShowActionsMenu(false);
}}
aria-label="More move options"
aria-haspopup="menu"
aria-expanded={showMoveMenu}
>
<span className="detail-move-btn__label">
Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
</span>
{hasSecondaryMoveOptions && (
<span className="detail-move-btn__arrow" aria-hidden="true">
<ChevronDown size={12} />
</button>
</>
)}
</span>
)}
</button>
{showMoveMenu && hasSecondaryMoveOptions && (
<div className="detail-move-menu detail-move-split-btn__menu" role="menu">
<div className="detail-move-menu" role="menu" onKeyDown={handleMoveMenuKeyDown}>
{secondaryMoveTransitions.map((col) => (
<button
key={col}
className="detail-move-menu-item"
role="menuitem"
onClick={() => handleMoveMenuItemClick(col)}
onKeyDown={handleMoveMenuKeyDown}
>
Move to {COLUMN_LABELS[col]}
</button>

View File

@@ -543,7 +543,7 @@ describe("TaskDetailModal", () => {
});
it("shows in-review split button with primary action and secondary move option", () => {
render(
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-review" })}
onClose={noop}
@@ -555,11 +555,12 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByRole("button", { name: "Move to Todo" })).toBeTruthy();
const chevronBtn = screen.getByRole("button", { name: "More move options" });
expect(chevronBtn).toBeTruthy();
const moveBtn = screen.getByRole("button", { name: "Move to Todo" });
expect(moveBtn).toBeTruthy();
const chevronZone = container.querySelector(".detail-move-btn__arrow");
expect(chevronZone).toBeTruthy();
fireEvent.click(chevronBtn);
fireEvent.keyDown(moveBtn, { key: "ArrowDown" });
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
@@ -589,16 +590,16 @@ describe("TaskDetailModal", () => {
expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy();
expect(screen.getAllByRole("menuitem", { name: "Retry" })).toHaveLength(1);
const chevronBtn = screen.getByRole("button", { name: "More move options" });
const chevronZone = document.querySelector(".detail-move-btn__arrow");
await act(async () => {
fireEvent.click(chevronBtn);
fireEvent.click(chevronZone!);
});
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
});
it("split-button renders with chevron when multiple transitions exist", async () => {
render(
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-progress" })}
onClose={noop}
@@ -610,12 +611,13 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByRole("button", { name: "Move to In Review" })).toBeTruthy();
const chevronBtn = screen.getByRole("button", { name: "More move options" });
expect(chevronBtn).toBeTruthy();
const moveBtn = screen.getByRole("button", { name: "Move to In Review" });
expect(moveBtn).toBeTruthy();
const chevronZone = container.querySelector(".detail-move-btn__arrow");
expect(chevronZone).toBeTruthy();
await act(async () => {
fireEvent.click(chevronBtn);
fireEvent.click(chevronZone!);
});
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
@@ -637,7 +639,7 @@ describe("TaskDetailModal", () => {
);
expect(screen.getByRole("button", { name: "Move to Todo" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "More move options" })).toBeNull();
expect(container.querySelector(".detail-move-btn__arrow")).toBeNull();
expect(container.querySelector(".detail-move-split-btn__divider")).toBeNull();
});
@@ -677,14 +679,17 @@ describe("TaskDetailModal", () => {
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "More move options" }));
});
const moveBtn = screen.getByRole("button", { name: "Move to In Review" });
fireEvent.keyDown(moveBtn, { key: "ArrowDown" });
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull();
fireEvent.keyDown(screen.getByRole("menuitem", { name: "Move to Todo" }), { key: "Escape" });
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
expect(document.activeElement).toBe(moveBtn);
});
});
@@ -2638,7 +2643,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Merge & Close")).toBeTruthy();
// Back to In Progress is in secondary move options
fireEvent.click(screen.getByRole("button", { name: "More move options" }));
fireEvent.click(document.querySelector(".detail-move-btn__arrow")!);
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
});

View File

@@ -816,7 +816,7 @@ describe("TaskForm preset selection (FN-819)", () => {
expect(fetchSettings).toHaveBeenCalled();
});
const presetSelect = document.getElementById("model-preset") as HTMLSelectElement;
const presetSelect = (await screen.findByLabelText("Preset")) as HTMLSelectElement;
fireEvent.change(presetSelect, { target: { value: "custom" } });
expect(onPresetModeChange).toHaveBeenCalledWith("custom");