Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ export function InboxSignalsTab() {
(s) => s.toggleReportSelection,
);
const selectRange = useInboxReportSelectionStore((s) => s.selectRange);
const selectExactRange = useInboxReportSelectionStore(
(s) => s.selectExactRange,
);
const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection);
const clearSelection = useInboxReportSelectionStore((s) => s.clearSelection);

Expand Down Expand Up @@ -292,25 +295,49 @@ export function InboxSignalsTab() {
}
}, [focusListPane, showTwoPaneLayout]);

// Tracks the cursor position for keyboard navigation (the "moving end" of
// Shift+Arrow selection). Separated from `lastClickedId` which acts as the
// anchor so that the anchor stays fixed while the cursor extends the range.
const keyboardCursorIdRef = useRef<string | null>(null);

const navigateReport = useCallback(
(direction: 1 | -1) => {
(direction: 1 | -1, shift: boolean) => {
const list = reportsRef.current;
if (list.length === 0) return;

// Find the current position based on the last selected report
const currentIds = selectedReportIdsRef.current;
const currentId =
currentIds.length > 0 ? currentIds[currentIds.length - 1] : null;
const currentIndex = currentId
? list.findIndex((r) => r.id === currentId)
// Determine cursor position — the item to navigate away from
const cursorId =
keyboardCursorIdRef.current ??
(selectedReportIdsRef.current.length > 0
? selectedReportIdsRef.current[
selectedReportIdsRef.current.length - 1
]
: null);
const cursorIndex = cursorId
? list.findIndex((r) => r.id === cursorId)
: -1;
const nextIndex =
currentIndex === -1
cursorIndex === -1
? 0
: Math.max(0, Math.min(list.length - 1, currentIndex + direction));
: Math.max(0, Math.min(list.length - 1, cursorIndex + direction));
const nextId = list[nextIndex].id;

setSelectedReportIds([nextId]);
if (shift) {
// Anchor is the store's lastClickedId — the point where shift-selection started.
// selectExactRange replaces the selection with the exact range from anchor to cursor,
// so reversing direction correctly contracts the selection.
const anchor =
useInboxReportSelectionStore.getState().lastClickedId ?? nextId;
selectExactRange(
anchor,
nextId,
list.map((r) => r.id),
);
keyboardCursorIdRef.current = nextId;
} else {
setSelectedReportIds([nextId]);
keyboardCursorIdRef.current = nextId;
}

const container = leftPaneRef.current;
const row = container?.querySelector<HTMLElement>(
Expand All @@ -326,7 +353,7 @@ export function InboxSignalsTab() {
row.style.scrollMarginTop = `${stickyHeaderHeight}px`;
row.scrollIntoView({ block: "nearest" });
},
[setSelectedReportIds],
[setSelectedReportIds, selectExactRange],
);

// Window-level keyboard handler so arrow keys work regardless of which
Expand All @@ -347,10 +374,10 @@ export function InboxSignalsTab() {

if (e.key === "ArrowDown") {
e.preventDefault();
navigateReport(1);
navigateReport(1, e.shiftKey);
} else if (e.key === "ArrowUp") {
e.preventDefault();
navigateReport(-1);
navigateReport(-1, e.shiftKey);
} else if (
e.key === "Escape" &&
selectedReportIdsRef.current.length > 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,78 @@ describe("inboxReportSelectionStore", () => {
expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r3");
});
});

describe("selectExactRange", () => {
const orderedIds = ["r1", "r2", "r3", "r4", "r5"];

it("selects exactly the range from anchor to target", () => {
useInboxReportSelectionStore
.getState()
.selectExactRange("r2", "r4", orderedIds);

expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
["r2", "r3", "r4"],
);
});

it("replaces existing selection instead of merging", () => {
useInboxReportSelectionStore.setState({
selectedReportIds: ["r1", "r5"],
});

useInboxReportSelectionStore
.getState()
.selectExactRange("r2", "r4", orderedIds);

expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
["r2", "r3", "r4"],
);
});

it("keeps lastClickedId as the anchor", () => {
useInboxReportSelectionStore
.getState()
.selectExactRange("r2", "r4", orderedIds);

expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r2");
});

it("contracts selection when cursor moves back toward anchor", () => {
// Simulate: anchor=r2, extend to r4, then contract back to r3
useInboxReportSelectionStore
.getState()
.selectExactRange("r2", "r4", orderedIds);
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
["r2", "r3", "r4"],
);

useInboxReportSelectionStore
.getState()
.selectExactRange("r2", "r3", orderedIds);
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
["r2", "r3"],
);
});

it("works in reverse direction", () => {
useInboxReportSelectionStore
.getState()
.selectExactRange("r4", "r2", orderedIds);

expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
["r2", "r3", "r4"],
);
expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r4");
});

it("selects just the target when anchor is not in the ordered list", () => {
useInboxReportSelectionStore
.getState()
.selectExactRange("r99", "r3", orderedIds);

expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
["r3"],
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ interface InboxReportSelectionActions {
/** Select a contiguous range from the last-clicked report to `toId` within the given ordered list.
* Existing selection outside the range is preserved (shift-click behavior). */
selectRange: (toId: string, orderedIds: string[]) => void;
/** Select exactly the contiguous range from `anchorId` to `toId`, replacing the entire selection.
* Unlike `selectRange`, this does not merge with existing selection — used for Shift+Arrow keyboard navigation. */
selectExactRange: (
anchorId: string,
toId: string,
orderedIds: string[],
) => void;
isReportSelected: (reportId: string) => boolean;
clearSelection: () => void;
pruneSelection: (visibleReportIds: string[]) => void;
Expand Down Expand Up @@ -67,6 +74,20 @@ export const useInboxReportSelectionStore = create<InboxReportSelectionStore>()(
return { selectedReportIds: merged, lastClickedId: toId };
}),

selectExactRange: (anchorId, toId, orderedIds) =>
set(() => {
const anchorIndex = orderedIds.indexOf(anchorId);
const toIndex = orderedIds.indexOf(toId);
if (anchorIndex === -1 || toIndex === -1) {
return { selectedReportIds: [toId], lastClickedId: toId };
}
const start = Math.min(anchorIndex, toIndex);
const end = Math.max(anchorIndex, toIndex);
const rangeIds = orderedIds.slice(start, end + 1);
// Keep lastClickedId as the anchor — the caller manages cursor position
return { selectedReportIds: rangeIds, lastClickedId: anchorId };
}),

isReportSelected: (reportId) => get().selectedReportIds.includes(reportId),

clearSelection: () => set({ selectedReportIds: [], lastClickedId: null }),
Expand Down
Loading