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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user