Skip to content

Commit 3e77d31

Browse files
committed
feat(ui): FLIP animation, poll scroll fix, and accordion UX improvements
- extracts withScrollLock to shared src/app/lib/scroll.ts with try/finally - adds withFlipAnimation (200ms ease-in-out, reduced-motion fallback) - replaces instant repo pin/move with FLIP animation via data-repo-group attrs - preserves scroll position on timed poll refresh in DashboardPage - refactors accordion: single bordered unit, inline Select/Deselect in header bar, loading spinner on collapsed headers, orgId sanitization for DOM IDs - refactors orgContent closure (removes isAccordion check), Show+fallback pattern - adds 12 new tests (1573 total, 71 files, typecheck clean)
1 parent c084327 commit 3e77d31

File tree

11 files changed

+431
-92
lines changed

11 files changed

+431
-92
lines changed

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ export default function ActionsTab(props: ActionsTabProps) {
291291
});
292292

293293
return (
294-
<div class="bg-base-100">
294+
<div class="bg-base-100" data-repo-group={repoGroup.repoFullName}>
295295
{/* Repo header */}
296296
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposActions().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
297297
<button

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { pushNotification } from "../../lib/errors";
2525
import { getClient, getGraphqlRateLimit } from "../../services/github";
2626
import { formatCount } from "../../lib/format";
2727
import { setsEqual } from "../../lib/collections";
28+
import { withScrollLock } from "../../lib/scroll";
2829
import { Tooltip } from "../shared/Tooltip";
2930

3031
// ── Shared dashboard store (module-level to survive navigation) ─────────────
@@ -173,13 +174,17 @@ async function pollFetch(): Promise<DashboardData> {
173174
} else {
174175
// Phase 1 did NOT fire (cached data existed or subsequent poll).
175176
// Full atomic replacement — all fields (light + heavy) may have
176-
// changed since the last cycle.
177-
setDashboardData({
178-
issues: data.issues,
179-
pullRequests: data.pullRequests,
180-
workflowRuns: data.workflowRuns,
181-
loading: false,
182-
lastRefreshedAt: now,
177+
// changed since the last cycle. Preserve scroll position: SolidJS
178+
// DOM updates are synchronous within the setter, so save/restore
179+
// around it to prevent scroll reset from <For> DOM rebuild.
180+
withScrollLock(() => {
181+
setDashboardData({
182+
issues: data.issues,
183+
pullRequests: data.pullRequests,
184+
workflowRuns: data.workflowRuns,
185+
loading: false,
186+
lastRefreshedAt: now,
187+
});
183188
});
184189
}
185190
rebuildHotSets(data);

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ export default function IssuesTab(props: IssuesTabProps) {
352352
});
353353

354354
return (
355-
<div class="bg-base-100">
355+
<div class="bg-base-100" data-repo-group={repoGroup.repoFullName}>
356356
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposIssues().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
357357
<button
358358
onClick={() => toggleExpandedRepo("issues", repoGroup.repoFullName)}

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
461461
});
462462

463463
return (
464-
<div class="bg-base-100">
464+
<div class="bg-base-100" data-repo-group={repoGroup.repoFullName}>
465465
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposPRs().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
466466
<button
467467
onClick={() => toggleExpandedRepo("pullRequests", repoGroup.repoFullName)}

src/app/components/onboarding/RepoSelector.tsx

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -463,44 +463,17 @@ export default function RepoSelector(props: RepoSelectorProps) {
463463

464464
{/* Per-org repo lists — Index (not For) avoids tearing down every org's
465465
DOM subtree when a single org's state updates via setOrgStates(prev.map(...)) */}
466+
<div class={isAccordion() ? "overflow-hidden rounded-lg border border-base-300 divide-y divide-base-300" : "contents"}>
466467
<Index each={sortedOrgStates()}>
467468
{(state) => {
468469
const visible = createMemo(() => filteredReposForOrg(state()));
469470
const selectedCount = createMemo(() =>
470471
visible().filter((r) => isSelected(r.fullName)).length
471472
);
473+
const orgId = createMemo(() => state().org.replace(/[^a-zA-Z0-9-]/g, "-"));
472474

473475
const orgContent = () => (
474476
<>
475-
{/* Per-org bulk selection bar (accordion mode only — in non-accordion, these are in the header) */}
476-
<Show when={isAccordion() && !state().loading && !state().error}>
477-
<div class="flex justify-end gap-2 border-b border-base-300 px-4 py-1">
478-
<button
479-
type="button"
480-
onClick={() => selectAllInOrg(state())}
481-
disabled={
482-
visible().length === 0 ||
483-
visible().every((r) => isSelected(r.fullName))
484-
}
485-
class="btn btn-ghost btn-xs"
486-
>
487-
Select All
488-
</button>
489-
<span class="text-base-content/30">&middot;</span>
490-
<button
491-
type="button"
492-
onClick={() => deselectAllInOrg(state())}
493-
disabled={
494-
visible().length === 0 ||
495-
visible().every((r) => !isSelected(r.fullName))
496-
}
497-
class="btn btn-ghost btn-xs"
498-
>
499-
Deselect All
500-
</button>
501-
</div>
502-
</Show>
503-
504477
{/* Loading state for this org */}
505478
<Show when={state().loading}>
506479
<div class="flex justify-center py-6">
@@ -602,7 +575,7 @@ export default function RepoSelector(props: RepoSelectorProps) {
602575
);
603576

604577
return (
605-
<div class="overflow-hidden rounded-lg border border-base-300">
578+
<div class={isAccordion() ? "" : "overflow-hidden rounded-lg border border-base-300"}>
606579
{/* Org header — accordion button when >= 6 orgs, plain header otherwise */}
607580
<Show
608581
when={isAccordion()}
@@ -641,38 +614,74 @@ export default function RepoSelector(props: RepoSelectorProps) {
641614
</div>
642615
}
643616
>
644-
<button
645-
type="button"
646-
id={`accordion-header-${state().org}`}
647-
class="flex w-full items-center gap-2 border-b border-base-300 bg-base-200 px-4 py-2 text-left"
648-
aria-expanded={expandedOrg() === state().org}
649-
aria-controls={`accordion-panel-${state().org}`}
650-
onClick={() => setUserExpandedOrg(state().org)}
651-
>
652-
<ChevronIcon size="md" rotated={expandedOrg() !== state().org} />
653-
<span class="text-sm font-semibold text-base-content flex-1">
654-
{state().org}
655-
</span>
656-
<span class="badge badge-sm badge-ghost">{visible().length} {visible().length === 1 ? "repo" : "repos"}</span>
657-
<Show when={selectedCount() > 0}>
658-
<span class="badge badge-sm badge-ghost">{selectedCount()} selected</span>
617+
<div class="flex items-center border-b border-base-300 bg-base-200">
618+
<button
619+
type="button"
620+
id={`accordion-header-${orgId()}`}
621+
class="flex flex-1 items-center gap-2 px-4 py-2 text-left"
622+
aria-expanded={expandedOrg() === state().org}
623+
aria-controls={`accordion-panel-${orgId()}`}
624+
// Always-one-open: clicking the already-expanded header is intentionally a no-op
625+
onClick={() => setUserExpandedOrg(state().org)}
626+
>
627+
<ChevronIcon size="md" rotated={expandedOrg() !== state().org} />
628+
<span class="text-sm font-semibold text-base-content flex-1">
629+
{state().org}
630+
</span>
631+
<Show
632+
when={!state().loading}
633+
fallback={<span class="loading loading-spinner loading-xs" />}
634+
>
635+
<span class="badge badge-sm badge-ghost">{visible().length} {visible().length === 1 ? "repo" : "repos"}</span>
636+
<Show when={selectedCount() > 0}>
637+
<span class="badge badge-sm badge-ghost">{selectedCount()} selected</span>
638+
</Show>
639+
</Show>
640+
</button>
641+
{/* Per-org bulk actions — inline in the header bar when expanded */}
642+
<Show when={expandedOrg() === state().org && !state().loading && !state().error}>
643+
<div class="flex items-center gap-2 pr-3">
644+
<button
645+
type="button"
646+
onClick={() => selectAllInOrg(state())}
647+
disabled={
648+
visible().length === 0 ||
649+
visible().every((r) => isSelected(r.fullName))
650+
}
651+
class="btn btn-ghost btn-xs"
652+
>
653+
Select All
654+
</button>
655+
<span class="text-base-content/30">·</span>
656+
<button
657+
type="button"
658+
onClick={() => deselectAllInOrg(state())}
659+
disabled={
660+
visible().length === 0 ||
661+
visible().every((r) => !isSelected(r.fullName))
662+
}
663+
class="btn btn-ghost btn-xs"
664+
>
665+
Deselect All
666+
</button>
667+
</div>
659668
</Show>
660-
</button>
669+
</div>
661670
</Show>
662671

663672
{/* Content: wrapped in grid animation for accordion, direct otherwise */}
664-
<Show when={!isAccordion()}>
665-
{orgContent()}
666-
</Show>
667-
<Show when={isAccordion()}>
673+
<Show
674+
when={isAccordion()}
675+
fallback={orgContent()}
676+
>
668677
<div
669678
class="accordion-panel grid transition-[grid-template-rows] duration-200"
670679
style={{ "grid-template-rows": expandedOrg() === state().org ? "1fr" : "0fr" }}
671680
>
672681
<div
673-
id={`accordion-panel-${state().org}`}
682+
id={`accordion-panel-${orgId()}`}
674683
role="region"
675-
aria-labelledby={`accordion-header-${state().org}`}
684+
aria-labelledby={`accordion-header-${orgId()}`}
676685
class="overflow-hidden"
677686
inert={expandedOrg() !== state().org}
678687
>
@@ -684,6 +693,7 @@ export default function RepoSelector(props: RepoSelectorProps) {
684693
);
685694
}}
686695
</Index>
696+
</div>
687697

688698
{/* Upstream Repositories section */}
689699
<Show when={props.showUpstreamDiscovery}>

src/app/components/shared/RepoLockControls.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
import { Show, createMemo } from "solid-js";
22
import { viewState, lockRepo, unlockRepo, moveLockedRepo, type LockedReposTab } from "../../stores/view";
33
import { Tooltip } from "./Tooltip";
4+
import { withFlipAnimation } from "../../lib/scroll";
45

56
interface RepoLockControlsProps {
67
tab: LockedReposTab;
78
repoFullName: string;
89
}
910

10-
const withScrollLock = (fn: () => void) => {
11-
const y = window.scrollY;
12-
fn();
13-
window.scrollTo(0, y);
14-
};
15-
1611
export default function RepoLockControls(props: RepoLockControlsProps) {
1712
const lockInfo = createMemo(() => {
1813
const list = viewState.lockedRepos[props.tab];
@@ -32,7 +27,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
3227
<Tooltip content="Pin to top">
3328
<button
3429
class="btn btn-ghost btn-xs opacity-0 group-hover/repo-header:opacity-100 focus:opacity-100 max-sm:opacity-60 sm:max-lg:opacity-60 transition-opacity"
35-
onClick={() => withScrollLock(() => lockRepo(props.tab, props.repoFullName))}
30+
onClick={() => withFlipAnimation(() => lockRepo(props.tab, props.repoFullName))}
3631
aria-label={`Pin ${props.repoFullName} to top of list`}
3732
>
3833
{/* Heroicons 20px solid: lock-open */}
@@ -46,7 +41,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
4641
<Tooltip content="Unpin">
4742
<button
4843
class="btn btn-ghost btn-xs"
49-
onClick={() => withScrollLock(() => unlockRepo(props.tab, props.repoFullName))}
44+
onClick={() => withFlipAnimation(() => unlockRepo(props.tab, props.repoFullName))}
5045
aria-label={`Unpin ${props.repoFullName}`}
5146
>
5247
{/* Heroicons 20px solid: lock-closed */}
@@ -58,7 +53,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
5853
<Tooltip content={lockInfo().isFirst ? "Already at top of pinned list" : "Move up"}>
5954
<button
6055
class="btn btn-ghost btn-xs"
61-
onClick={() => withScrollLock(() => moveLockedRepo(props.tab, props.repoFullName, "up"))}
56+
onClick={() => withFlipAnimation(() => moveLockedRepo(props.tab, props.repoFullName, "up"))}
6257
disabled={lockInfo().isFirst}
6358
aria-label={`Move ${props.repoFullName} up`}
6459
>
@@ -71,7 +66,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
7166
<Tooltip content={lockInfo().isLast ? "Already at bottom of pinned list" : "Move down"}>
7267
<button
7368
class="btn btn-ghost btn-xs"
74-
onClick={() => withScrollLock(() => moveLockedRepo(props.tab, props.repoFullName, "down"))}
69+
onClick={() => withFlipAnimation(() => moveLockedRepo(props.tab, props.repoFullName, "down"))}
7570
disabled={lockInfo().isLast}
7671
aria-label={`Move ${props.repoFullName} down`}
7772
>

src/app/lib/scroll.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Wraps a synchronous function call with scroll position preservation.
3+
* SolidJS fine-grained DOM updates complete synchronously within fn(),
4+
* so a single synchronous scrollTo is sufficient — no rAF needed.
5+
*/
6+
export function withScrollLock(fn: () => void): void {
7+
const y = window.scrollY;
8+
try { fn(); } finally { window.scrollTo(0, y); }
9+
}
10+
11+
/**
12+
* FLIP animation for reorderable lists. Records positions of elements
13+
* matching `[data-repo-group]` before fn(), then animates them to their
14+
* new positions after the DOM update.
15+
*
16+
* Consistent with TrackedTab's FLIP: 200ms ease-in-out, respects
17+
* prefers-reduced-motion (falls back to withScrollLock).
18+
*/
19+
export function withFlipAnimation(fn: () => void): void {
20+
if (typeof window === "undefined") { fn(); return; }
21+
22+
// Reduced motion: fall back to instant scroll lock
23+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
24+
withScrollLock(fn);
25+
return;
26+
}
27+
28+
// First: record positions of all repo group wrappers
29+
const items = document.querySelectorAll<HTMLElement>("[data-repo-group]");
30+
const before = new Map<string, DOMRect>();
31+
for (const el of items) {
32+
const key = el.dataset.repoGroup!;
33+
before.set(key, el.getBoundingClientRect());
34+
}
35+
36+
// Execute state change (SolidJS updates DOM synchronously)
37+
fn();
38+
39+
// Last, Invert, Play
40+
requestAnimationFrame(() => {
41+
const afterItems = document.querySelectorAll<HTMLElement>("[data-repo-group]");
42+
for (const el of afterItems) {
43+
const key = el.dataset.repoGroup!;
44+
const old = before.get(key);
45+
if (!old) continue;
46+
const now = el.getBoundingClientRect();
47+
const dy = old.top - now.top;
48+
if (Math.abs(dy) < 1) continue;
49+
el.animate(
50+
[{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }],
51+
{ duration: 200, easing: "ease-in-out" },
52+
);
53+
}
54+
});
55+
}

tests/components/DashboardPage.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,36 @@ describe("DashboardPage — onAuthCleared integration", () => {
681681
});
682682
});
683683

684+
describe("DashboardPage — scroll preservation on poll refresh", () => {
685+
it("preserves scroll position when setDashboardData replaces arrays", async () => {
686+
const issues = [makeIssue({ id: 1, title: "Scroll test issue" })];
687+
vi.mocked(pollService.fetchAllData).mockResolvedValue({
688+
issues,
689+
pullRequests: [],
690+
workflowRuns: [],
691+
errors: [],
692+
});
693+
694+
render(() => <DashboardPage />);
695+
await waitFor(() => {
696+
screen.getByText("owner/repo");
697+
});
698+
699+
// Simulate user scrolled down
700+
document.documentElement.scrollTop = 500;
701+
vi.spyOn(window, "scrollTo");
702+
703+
// Trigger a second poll (subsequent refresh — the path that uses withScrollLock)
704+
if (capturedFetchAll) {
705+
await capturedFetchAll();
706+
}
707+
708+
expect(window.scrollTo).toHaveBeenCalledWith(0, 500);
709+
vi.restoreAllMocks();
710+
document.documentElement.scrollTop = 0;
711+
});
712+
});
713+
684714
describe("DashboardPage — onHotData integration", () => {
685715
it("applies hot poll PR status updates to the store", async () => {
686716
const testPR = makePullRequest({

0 commit comments

Comments
 (0)