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; align-items: center;
} }
.detail-move-split-btn { .detail-move-btn {
position: relative; position: relative;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.detail-move-btn__label {
flex: 1;
}
.detail-move-btn__arrow {
display: flex; display: flex;
align-items: stretch; align-items: center;
border-radius: var(--radius-md); justify-content: center;
} padding: var(--space-xs);
margin-right: calc(-1 * var(--space-xs));
.detail-move-split-btn__main { margin-block: calc(-1 * var(--space-xs));
border-top-right-radius: 0; border-radius: 0 var(--radius-md) var(--radius-md) 0;
border-bottom-right-radius: 0; cursor: pointer;
}
.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;
} }
@media (hover: hover) { @media (hover: hover) {
.detail-move-split-btn__chevron:hover { .detail-move-btn:hover .detail-move-btn__arrow {
background: var(--cta-bg-hover, var(--cta-bg)); background: color-mix(in srgb, var(--text) 12%, transparent);
} }
} }
@@ -708,10 +697,6 @@
right: 0; right: 0;
} }
.detail-move-split-btn__menu {
right: 0;
}
.detail-actions-menu-item, .detail-actions-menu-item,
.detail-move-menu-item { .detail-move-menu-item {
display: block; display: block;
@@ -739,8 +724,8 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.detail-move-split-btn__chevron { .detail-move-btn__arrow {
min-width: calc(var(--space-md) + var(--space-sm)); padding: var(--space-xs);
} }
/* Full-screen sheet on mobile. Desktop `min-width: 480px` would otherwise /* 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 [showMoveMenu, setShowMoveMenu] = useState(false);
const [showActionsMenu, setShowActionsMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false);
const moveMenuRef = useRef<HTMLDivElement>(null); const moveMenuRef = useRef<HTMLDivElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null); const actionsMenuRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:task-detail-modal-size"); useModalResizePersist(modalRef, true, "fusion:task-detail-modal-size");
@@ -1279,6 +1280,71 @@ export function TaskDetailModal({
const primaryMoveTransition = moveTransitions[0]; const primaryMoveTransition = moveTransitions[0];
const secondaryMoveTransitions = moveTransitions.slice(1); const secondaryMoveTransitions = moveTransitions.slice(1);
const hasSecondaryMoveOptions = secondaryMoveTransitions.length > 0; 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> = { const prAutomationStatusLabels: Record<string, string> = {
"creating-pr": "Creating PR…", "creating-pr": "Creating PR…",
"awaiting-pr-checks": "Awaiting PR checks", "awaiting-pr-checks": "Awaiting PR checks",
@@ -2171,39 +2237,35 @@ export function TaskDetailModal({
<div className="detail-move-dropdown" ref={moveMenuRef}> <div className="detail-move-dropdown" ref={moveMenuRef}>
{task.column === "in-review" ? ( {task.column === "in-review" ? (
<div className="detail-move-actions-in-review"> <div className="detail-move-actions-in-review">
<div className="detail-move-split-btn"> <div>
<button <button
className="btn btn-primary btn-sm detail-move-split-btn__main" ref={moveButtonRef}
onClick={() => primaryMoveTransition && handleMoveMenuItemClick(primaryMoveTransition)} className="btn btn-primary btn-sm detail-move-btn"
onClick={handleMoveButtonClick}
onKeyDown={handleMoveButtonKeyDown}
disabled={!primaryMoveTransition} 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] : ""} <span className="detail-move-btn__label">
</button> Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
{hasSecondaryMoveOptions && ( </span>
<> {hasSecondaryMoveOptions && (
<span className="detail-move-split-btn__divider" aria-hidden="true" /> <span className="detail-move-btn__arrow" 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}
>
<ChevronDown size={12} /> <ChevronDown size={12} />
</button> </span>
</> )}
)} </button>
{showMoveMenu && hasSecondaryMoveOptions && ( {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) => ( {secondaryMoveTransitions.map((col) => (
<button <button
key={col} key={col}
className="detail-move-menu-item" className="detail-move-menu-item"
role="menuitem" role="menuitem"
onClick={() => handleMoveMenuItemClick(col)} onClick={() => handleMoveMenuItemClick(col)}
onKeyDown={handleMoveMenuKeyDown}
> >
{col === "in-progress" ? "Back to In Progress" : `Move to ${COLUMN_LABELS[col]}`} {col === "in-progress" ? "Back to In Progress" : `Move to ${COLUMN_LABELS[col]}`}
</button> </button>
@@ -2222,39 +2284,35 @@ export function TaskDetailModal({
)} )}
</div> </div>
) : ( ) : (
<div className="detail-move-split-btn"> <div>
<button <button
className="btn btn-primary btn-sm detail-move-split-btn__main" ref={moveButtonRef}
onClick={() => primaryMoveTransition && handleMoveMenuItemClick(primaryMoveTransition)} className="btn btn-primary btn-sm detail-move-btn"
onClick={handleMoveButtonClick}
onKeyDown={handleMoveButtonKeyDown}
disabled={!primaryMoveTransition} 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] : ""} <span className="detail-move-btn__label">
</button> Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
{hasSecondaryMoveOptions && ( </span>
<> {hasSecondaryMoveOptions && (
<span className="detail-move-split-btn__divider" aria-hidden="true" /> <span className="detail-move-btn__arrow" 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}
>
<ChevronDown size={12} /> <ChevronDown size={12} />
</button> </span>
</> )}
)} </button>
{showMoveMenu && hasSecondaryMoveOptions && ( {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) => ( {secondaryMoveTransitions.map((col) => (
<button <button
key={col} key={col}
className="detail-move-menu-item" className="detail-move-menu-item"
role="menuitem" role="menuitem"
onClick={() => handleMoveMenuItemClick(col)} onClick={() => handleMoveMenuItemClick(col)}
onKeyDown={handleMoveMenuKeyDown}
> >
Move to {COLUMN_LABELS[col]} Move to {COLUMN_LABELS[col]}
</button> </button>

View File

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