Skip to content

Commit c084327

Browse files
committed
feat(ui): adds org-grouped accordion to repo picker for 6+ orgs
1 parent 7ec3d45 commit c084327

File tree

3 files changed

+483
-42
lines changed

3 files changed

+483
-42
lines changed

src/app/components/onboarding/RepoSelector.tsx

Lines changed: 130 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { relativeTime } from "../../lib/format";
1515
import LoadingSpinner from "../shared/LoadingSpinner";
1616
import FilterInput from "../shared/FilterInput";
1717
import { Tooltip, InfoTooltip } from "../shared/Tooltip";
18+
import ChevronIcon from "../shared/ChevronIcon";
1819

1920
// Validates owner/repo format (both segments must be non-empty, no spaces)
2021
const VALID_REPO_NAME = /^[a-zA-Z0-9._-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
@@ -407,6 +408,18 @@ export default function RepoSelector(props: RepoSelectorProps) {
407408
);
408409
});
409410

411+
// ── Accordion state ───────────────────────────────────────────────────────
412+
413+
const [userExpandedOrg, setUserExpandedOrg] = createSignal<string | null>(null);
414+
const isAccordion = createMemo(() => sortedOrgStates().length >= 6);
415+
const expandedOrg = createMemo(() => {
416+
if (!isAccordion()) return null;
417+
const states = sortedOrgStates();
418+
const userChoice = userExpandedOrg();
419+
if (userChoice !== null && states.some(s => s.org === userChoice)) return userChoice;
420+
return states.length > 0 ? states[0].org : null;
421+
});
422+
410423
// ── Status ────────────────────────────────────────────────────────────────
411424

412425
const totalOrgs = () => props.selectedOrgs.length;
@@ -453,42 +466,40 @@ export default function RepoSelector(props: RepoSelectorProps) {
453466
<Index each={sortedOrgStates()}>
454467
{(state) => {
455468
const visible = createMemo(() => filteredReposForOrg(state()));
469+
const selectedCount = createMemo(() =>
470+
visible().filter((r) => isSelected(r.fullName)).length
471+
);
456472

457-
return (
458-
<div class="overflow-hidden rounded-lg border border-base-300">
459-
{/* Org header */}
460-
<div class="flex items-center justify-between border-b border-base-300 bg-base-200 px-4 py-2">
461-
<span class="text-sm font-semibold text-base-content">
462-
{state().org}
463-
</span>
464-
<Show when={!state().loading && !state().error}>
465-
<div class="flex gap-2">
466-
<button
467-
type="button"
468-
onClick={() => selectAllInOrg(state())}
469-
disabled={
470-
visible().length === 0 ||
471-
visible().every((r) => isSelected(r.fullName))
472-
}
473-
class="btn btn-ghost btn-xs"
474-
>
475-
Select All
476-
</button>
477-
<span class="text-base-content/30">·</span>
478-
<button
479-
type="button"
480-
onClick={() => deselectAllInOrg(state())}
481-
disabled={
482-
visible().length === 0 ||
483-
visible().every((r) => !isSelected(r.fullName))
484-
}
485-
class="btn btn-ghost btn-xs"
486-
>
487-
Deselect All
488-
</button>
489-
</div>
490-
</Show>
491-
</div>
473+
const orgContent = () => (
474+
<>
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>
492503

493504
{/* Loading state for this org */}
494505
<Show when={state().loading}>
@@ -532,8 +543,7 @@ export default function RepoSelector(props: RepoSelectorProps) {
532543
>
533544
<ul class="divide-y divide-base-300">
534545
<Index each={visible()}>
535-
{(repo) => {
536-
return (
546+
{(repo) => (
537547
<li>
538548
<div class="flex items-center">
539549
<label class="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-base-200 flex-1">
@@ -582,13 +592,94 @@ export default function RepoSelector(props: RepoSelectorProps) {
582592
</Show>
583593
</div>
584594
</li>
585-
);
586-
}}
595+
)}
587596
</Index>
588597
</ul>
589598
</div>
590599
</Show>
591600
</Show>
601+
</>
602+
);
603+
604+
return (
605+
<div class="overflow-hidden rounded-lg border border-base-300">
606+
{/* Org header — accordion button when >= 6 orgs, plain header otherwise */}
607+
<Show
608+
when={isAccordion()}
609+
fallback={
610+
<div class="flex items-center justify-between border-b border-base-300 bg-base-200 px-4 py-2">
611+
<span class="text-sm font-semibold text-base-content">
612+
{state().org}
613+
</span>
614+
<Show when={!state().loading && !state().error}>
615+
<div class="flex gap-2">
616+
<button
617+
type="button"
618+
onClick={() => selectAllInOrg(state())}
619+
disabled={
620+
visible().length === 0 ||
621+
visible().every((r) => isSelected(r.fullName))
622+
}
623+
class="btn btn-ghost btn-xs"
624+
>
625+
Select All
626+
</button>
627+
<span class="text-base-content/30">·</span>
628+
<button
629+
type="button"
630+
onClick={() => deselectAllInOrg(state())}
631+
disabled={
632+
visible().length === 0 ||
633+
visible().every((r) => !isSelected(r.fullName))
634+
}
635+
class="btn btn-ghost btn-xs"
636+
>
637+
Deselect All
638+
</button>
639+
</div>
640+
</Show>
641+
</div>
642+
}
643+
>
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>
659+
</Show>
660+
</button>
661+
</Show>
662+
663+
{/* Content: wrapped in grid animation for accordion, direct otherwise */}
664+
<Show when={!isAccordion()}>
665+
{orgContent()}
666+
</Show>
667+
<Show when={isAccordion()}>
668+
<div
669+
class="accordion-panel grid transition-[grid-template-rows] duration-200"
670+
style={{ "grid-template-rows": expandedOrg() === state().org ? "1fr" : "0fr" }}
671+
>
672+
<div
673+
id={`accordion-panel-${state().org}`}
674+
role="region"
675+
aria-labelledby={`accordion-header-${state().org}`}
676+
class="overflow-hidden"
677+
inert={expandedOrg() !== state().org}
678+
>
679+
{orgContent()}
680+
</div>
681+
</div>
682+
</Show>
592683
</div>
593684
);
594685
}}

src/app/index.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,7 @@
145145
.loading {
146146
animation: none;
147147
}
148+
.accordion-panel {
149+
transition: none;
150+
}
148151
}

0 commit comments

Comments
 (0)