Sidebar: no settings flash on the way into a repository, and a better slide
The repository menu is built from the address being gone to, so it is right from the first frame instead of the settings menu standing in until the page loads; its counts fill in when they arrive. The menus are two layers now: the arriving one slides in over the full width while the leaving one drifts a quarter of the way and fades, and moving between two far-side menus crossfades in place.
2 files+82−230/2 viewed
| 218 | 218 | background: radial-gradient(circle at 70% 30%, rgb(182 168 255 / 0.12), rgb(255 189 140 / 0.04) 45%, transparent 70%); | |
| 219 | 219 | box-shadow: inset 14px -8px 50px rgb(255 214 186 / 0.07); | |
| 220 | 220 | } | |
| 221 | + | ||
| 222 | + | /* The sidebar trading one far-side menu for another, in place. */ | |
| 223 | + | @keyframes g1t-swap { | |
| 224 | + | from { | |
| 225 | + | opacity: 0; | |
| 226 | + | transform: translateX(10px); | |
| 227 | + | } | |
| 228 | + | to { | |
| 229 | + | opacity: 1; | |
| 230 | + | transform: none; | |
| 231 | + | } | |
| 232 | + | } |
| 254 | 254 | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/; | |
| 255 | 255 | ||
| 256 | 256 | /** | |
| 257 | − | * The sidebar's menus sit side by side on one track, and the track slides: | |
| 258 | − | * the main menu leaves to the left as the settings arrive from the right, | |
| 259 | − | * together, with a long ease-out, the way a phone pushes a screen. | |
| 257 | + | * The sidebar's menus are two layers, the way a phone pushes a screen: the | |
| 258 | + | * one arriving slides in from the right over the full width, while the one | |
| 259 | + | * leaving drifts a quarter of the way left and fades, both on one long | |
| 260 | + | * ease-out. Going back reverses it. | |
| 260 | 261 | */ | |
| 261 | − | const TRACK = "flex h-full w-[200%] transition-transform duration-[380ms] ease-[cubic-bezier(0.45,0,0.15,1)] will-change-transform motion-reduce:transition-none"; | |
| 262 | − | /** One menu on the track. */ | |
| 263 | − | const PANEL = "h-full w-1/2 shrink-0 overflow-y-auto px-2 pb-4"; | |
| 262 | + | const LAYER = | |
| 263 | + | "absolute inset-0 transition-[transform,opacity] duration-[380ms] ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform motion-reduce:transition-none"; | |
| 264 | + | /** One menu, filling its layer. */ | |
| 265 | + | const PANEL = "h-full overflow-y-auto px-2 pb-4"; | |
| 264 | 266 | ||
| 265 | 267 | /** A workspace's settings, as the sidebar shows them in place of everything else. */ | |
| 266 | 268 | function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) { | |
| 306 | 308 | ); | |
| 307 | 309 | } | |
| 308 | 310 | ||
| 309 | − | type ActiveRepo = NonNullable<ShellData["repo"]>; | |
| 311 | + | /** | |
| 312 | + | * What the repository menu needs. Known from the address before the page's | |
| 313 | + | * data arrives, so the menu is right from the first frame; the counts fill | |
| 314 | + | * in once it does. | |
| 315 | + | */ | |
| 316 | + | type MenuRepo = { | |
| 317 | + | namespace: string; | |
| 318 | + | name: string; | |
| 319 | + | member: boolean; | |
| 320 | + | issues?: number; | |
| 321 | + | pulls?: number; | |
| 322 | + | }; | |
| 323 | + | ||
| 324 | + | function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) { | |
| 325 | + | return ( | |
| 326 | + | a != null && | |
| 327 | + | b != null && | |
| 328 | + | a.namespace.toLowerCase() === b.namespace.toLowerCase() && | |
| 329 | + | a.name.toLowerCase() === b.name.toLowerCase() | |
| 330 | + | ); | |
| 331 | + | } | |
| 310 | 332 | ||
| 311 | 333 | /** | |
| 312 | 334 | * A repository's own menu, which the sidebar slides to while you are in | |
| 313 | 335 | * it, as it does for settings: everything about the repository and nothing | |
| 314 | 336 | * else, with the way back to everything. | |
| 315 | 337 | */ | |
| 316 | − | function RepoMenu({ repo, isPrivate, open }: { repo: ActiveRepo; isPrivate: boolean; open: boolean }) { | |
| 338 | + | function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) { | |
| 317 | 339 | const base = `/${repo.namespace}/${repo.name}`; | |
| 318 | 340 | return ( | |
| 319 | 341 | <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}> | |
| 430 | 452 | const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]); | |
| 431 | 453 | const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-"; | |
| 432 | 454 | const away = inSettings || inAccount || inRepo; | |
| 455 | + | // The repository the menu is for: the one loaded if it is the one being | |
| 456 | + | // gone to, else what the address says, at once. | |
| 457 | + | const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null; | |
| 458 | + | const menuRepo: MenuRepo | null = targetRepo | |
| 459 | + | ? sameRepo(active, targetRepo) | |
| 460 | + | ? active | |
| 461 | + | : { | |
| 462 | + | ...targetRepo, | |
| 463 | + | member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()), | |
| 464 | + | } | |
| 465 | + | : null; | |
| 433 | 466 | // What sits on the far side of the track. Kept while sliding back, so it | |
| 434 | 467 | // does not vanish on the way out. | |
| 435 | 468 | const side = useRef<"workspace" | "account" | "repo">("workspace"); | |
| 437 | 470 | else if (inSettings) side.current = "workspace"; | |
| 438 | 471 | else if (inRepo) side.current = "repo"; | |
| 439 | 472 | // The repository last shown, kept for the slide back. | |
| 440 | − | const shown = useRef(active); | |
| 441 | − | if (active) shown.current = active; | |
| 473 | + | const shown = useRef<MenuRepo | null>(menuRepo); | |
| 474 | + | if (menuRepo) shown.current = menuRepo; | |
| 475 | + | // Moving between two of the far-side menus (settings to a repository, | |
| 476 | + | // one repository to another) crossfades in place instead of sliding. | |
| 477 | + | const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current; | |
| 478 | + | const lastAway = useRef(away); | |
| 479 | + | const lastKey = useRef(detailKey); | |
| 480 | + | const swapped = lastAway.current && away && lastKey.current !== detailKey; | |
| 481 | + | useEffect(() => { | |
| 482 | + | lastAway.current = away; | |
| 483 | + | lastKey.current = detailKey; | |
| 484 | + | }); | |
| 442 | 485 | // The repository being looked at is listed even when it is someone else's. | |
| 443 | 486 | const listed = | |
| 444 | 487 | active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name) | |
| 467 | 510 | <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd> | |
| 468 | 511 | </button> | |
| 469 | 512 | </div> | |
| 470 | − | <div className="min-h-0 grow overflow-hidden"> | |
| 471 | − | <div className={`${TRACK} ${away ? "-translate-x-1/2" : "translate-x-0"}`}> | |
| 513 | + | <div className="relative min-h-0 grow overflow-hidden"> | |
| 514 | + | <div className={`${LAYER} ${away ? "pointer-events-none -translate-x-1/4 opacity-0" : "translate-x-0 opacity-100"}`}> | |
| 472 | 515 | <nav aria-label="g1t" inert={away} className={PANEL}> | |
| 473 | 516 | <div className="mt-3 space-y-px"> | |
| 474 | 517 | <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}> | |
| 516 | 559 | })} | |
| 517 | 560 | </SidebarGroup> | |
| 518 | 561 | </nav> | |
| 519 | − | {side.current === "repo" && shown.current ? ( | |
| 520 | − | <RepoMenu | |
| 521 | − | repo={shown.current} | |
| 522 | − | isPrivate={shell.repos.some((repo) => repo.namespace === shown.current!.namespace && repo.name === shown.current!.name && repo.isPrivate)} | |
| 523 | − | open={inRepo} | |
| 524 | − | /> | |
| 525 | − | ) : side.current === "account" || !ws ? ( | |
| 526 | − | <AccountSettingsMenu open={inAccount} /> | |
| 527 | − | ) : ( | |
| 528 | − | <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} /> | |
| 529 | − | )} | |
| 530 | 562 | </div> | |
| 563 | + | <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}> | |
| 564 | + | <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}> | |
| 565 | + | {side.current === "repo" && shown.current ? ( | |
| 566 | + | <RepoMenu | |
| 567 | + | repo={shown.current} | |
| 568 | + | isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)} | |
| 569 | + | open={inRepo} | |
| 570 | + | /> | |
| 571 | + | ) : side.current === "account" || !ws ? ( | |
| 572 | + | <AccountSettingsMenu open={inAccount} /> | |
| 573 | + | ) : ( | |
| 574 | + | <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} /> | |
| 575 | + | )} | |
| 576 | + | </div> | |
| 577 | + | </div> | |
| 531 | 578 | </div> | |
| 532 | 579 | ||
| 533 | 580 | {/* The workspace's own things sit at the bottom, by its usage and the account. */} |