diff --git a/web/src/app.css b/web/src/app.css index e77fabe1..de08cf6d 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -253,18 +253,61 @@ line-height: 1.25rem; } +/* One tab's content giving way to another's. TabContent publishes the motion as + custom properties on the region it wraps; the rules below decide which boxes + actually travel, because a panel welded to the tab bar above it has to hold + still — a border sliding out from under the active tab breaks the join. */ +@keyframes tab-in { + from { + translate: var(--tab-from, 0); + opacity: 0; + } +} + +@layer components { + /* everything a tab route lays down travels as one, so a page carrying content + below its card — an overview's readme, an issue list — moves with it. only a + panel's own box is held back, which means everything inside it still travels: + a settings sidebar is chrome for its own sub-nav but content for this move, + arriving on screen for the first time along with the rest */ + [data-tab-motion] > *:not([data-tab-panel]), + [data-tab-motion] > [data-tab-panel] > * { + translate: var(--tab-shift, 0); + opacity: var(--tab-fade, 1); + transition: + translate var(--tab-out-duration, 0ms) var(--tab-out-ease, linear), + opacity var(--tab-out-duration, 0ms) var(--tab-out-ease, linear); + animation: var(--tab-enter, none) var(--tab-in-duration, 0ms) var(--tab-in-ease, linear) both; + } + + /* the panel keeps its box and clips whatever its body slides past. `clip` and + not `hidden`, so the vertical axis stays visible and the sticky diff headers + inside still resolve against the document */ + [data-tab-motion] > [data-tab-panel] { + overflow-x: clip; + } +} + @utility popover-transition { opacity: 0; scale: 0.98; + /* a transition reads off the state it lands on, so this one is the exit. the + two discrete properties are what keep a closing panel painted long enough + to run it, and firefox has neither — it leans on the popoverExit + attachment instead, which every panel using this utility needs */ transition: - opacity 120ms ease-out, - scale 120ms ease-out, - overlay 120ms allow-discrete, - display 120ms allow-discrete; + opacity 100ms ease-in, + scale 100ms ease-in, + overlay 100ms allow-discrete, + display 100ms allow-discrete; &:popover-open { opacity: 1; scale: 1; + /* and this one is the entry */ + transition: + opacity 75ms ease-out, + scale 75ms ease-out; } @starting-style { diff --git a/web/src/lib/components/profile/ProfileTabs.svelte b/web/src/lib/components/profile/ProfileTabs.svelte index 1b9b7b61..db1c792a 100644 --- a/web/src/lib/components/profile/ProfileTabs.svelte +++ b/web/src/lib/components/profile/ProfileTabs.svelte @@ -1,10 +1,6 @@ diff --git a/web/src/lib/components/profile/tabs/defs.ts b/web/src/lib/components/profile/tabs/defs.ts new file mode 100644 index 00000000..febbb4d6 --- /dev/null +++ b/web/src/lib/components/profile/tabs/defs.ts @@ -0,0 +1,34 @@ +// the profile tab bar, shared by the nav that draws it and the layout's content +// transition — both have to agree on the ids and on their left-to-right order, +// which is what decides the direction the content slides + +import SquareChartGantt from "$icon/square-chart-gantt"; +import BookMarked from "$icon/book-marked"; +import Star from "$icon/star"; +import LineSquiggle from "$icon/line-squiggle"; +import Shield from "$icon/shield"; +import type { TabDef } from "$lib/components/ui/Tabs.svelte"; +import type { ProfileCounts } from "../types"; + +export const profileTabs = (handle: string, counts: ProfileCounts): TabDef[] => { + const href = (id: string) => (id === "overview" ? `/${handle}` : `/${handle}?tab=${id}`); + return [ + { id: "overview", label: "Overview", icon: SquareChartGantt, href: href("overview") }, + { + id: "repos", + label: "Repositories", + icon: BookMarked, + count: counts.repos, + href: href("repos") + }, + { id: "starred", label: "Starred", icon: Star, count: counts.stars, href: href("starred") }, + { + id: "strings", + label: "Strings", + icon: LineSquiggle, + count: counts.strings, + href: href("strings") + }, + { id: "vouches", label: "Vouches", icon: Shield, href: href("vouches") } + ]; +}; diff --git a/web/src/lib/components/repo/RepoTabs.svelte b/web/src/lib/components/repo/RepoTabs.svelte index b7826854..b6254c63 100644 --- a/web/src/lib/components/repo/RepoTabs.svelte +++ b/web/src/lib/components/repo/RepoTabs.svelte @@ -1,10 +1,6 @@ diff --git a/web/src/lib/components/repo/tabs.ts b/web/src/lib/components/repo/tabs.ts new file mode 100644 index 00000000..44fccfc5 --- /dev/null +++ b/web/src/lib/components/repo/tabs.ts @@ -0,0 +1,36 @@ +// the repository tab bar, shared by the nav that draws it and the layout's +// content transition — both have to agree on the ids and on their left-to-right +// order, which is what decides the direction the content slides + +import SquareChartGantt from "$icon/square-chart-gantt"; +import CircleDot from "$icon/circle-dot"; +import GitPullRequest from "$icon/git-pull-request"; +import Layers2 from "$icon/layers-2"; +import Settings from "$icon/settings"; +import type { TabDef } from "$lib/components/ui/Tabs.svelte"; +import type { RepoCounts, RepoInfo } from "./types"; + +export const repoTabs = (repo: RepoInfo, counts: RepoCounts): TabDef[] => { + const base = `/${repo.ownerHandle}/${repo.name}`; + return [ + { id: "overview", label: "Overview", icon: SquareChartGantt, href: base }, + { + id: "issues", + label: "Issues", + icon: CircleDot, + count: counts.issues, + href: `${base}/issues` + }, + { + id: "pulls", + label: "Pulls", + icon: GitPullRequest, + count: counts.pulls, + href: `${base}/pulls` + }, + { id: "pipelines", label: "Pipelines", icon: Layers2, href: `${base}/pipelines` }, + // todo: only show this to collaborators — needs the viewer's push access on + // RepoInfo, which the layout load doesn't resolve yet + { id: "settings", label: "Settings", icon: Settings, href: `${base}/settings` } + ]; +}; diff --git a/web/src/lib/components/settings/SettingsPanel.svelte b/web/src/lib/components/settings/SettingsPanel.svelte index 217fe963..cde6e50d 100644 --- a/web/src/lib/components/settings/SettingsPanel.svelte +++ b/web/src/lib/components/settings/SettingsPanel.svelte @@ -15,8 +15,11 @@ +
diff --git a/web/src/lib/components/ui/TabContent.svelte b/web/src/lib/components/ui/TabContent.svelte new file mode 100644 index 00000000..2d78a8df --- /dev/null +++ b/web/src/lib/components/ui/TabContent.svelte @@ -0,0 +1,114 @@ + + + + +
+ {@render children()} +
diff --git a/web/src/lib/components/ui/TabPanel.svelte b/web/src/lib/components/ui/TabPanel.svelte index c8d3ad0d..5396a281 100644 --- a/web/src/lib/components/ui/TabPanel.svelte +++ b/web/src/lib/components/ui/TabPanel.svelte @@ -2,12 +2,20 @@ import { tv } from "tailwind-variants"; export const tabPanel = tv({ - base: "rounded-sm border border-border-default bg-background-default text-foreground-default", + slots: { + // the card itself: chrome only, so its box can hold still and stay welded to + // the tab bar above it while a transition moves what's inside + surface: + "rounded-sm border border-border-default bg-background-default text-foreground-default", + // everything the caller lays out lives in here, which is also what a tab + // transition travels — see the [data-tab-motion] rules in app.css + body: "" + }, variants: { // the common surface padding; turn off to lay out children yourself padded: { - true: "px-6 py-4", - false: "" + true: { body: "px-6 py-4" }, + false: {} } }, defaultVariants: { @@ -28,8 +36,12 @@ } let { as = "section", padded = true, class: className, children }: Props = $props(); + + const style = $derived(tabPanel({ padded })); - - {@render children()} + +
+ {@render children()} +
diff --git a/web/src/lib/components/ui/Tabs.svelte b/web/src/lib/components/ui/Tabs.svelte index f1858e7e..7de792ab 100644 --- a/web/src/lib/components/ui/Tabs.svelte +++ b/web/src/lib/components/ui/Tabs.svelte @@ -1,4 +1,6 @@ + - {@render children()} + + {@render children()} + diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index a5b82740..36256f6a 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -2,6 +2,7 @@ import { page } from "$app/state"; import type { SettingsNavItem } from "$lib/components/settings/SettingsNav.svelte"; import SettingsPanel from "$lib/components/settings/SettingsPanel.svelte"; + import TabContent from "$lib/components/ui/TabContent.svelte"; import User from "$icon/user-round"; import Key from "$icon/key"; import Mail from "$icon/mail"; @@ -16,7 +17,12 @@ { id: "profile", label: "Profile", href: "/settings", icon: User }, { id: "keys", label: "Keys", href: "/settings/keys", icon: Key }, { id: "emails", label: "Emails", href: "/settings/emails", icon: Mail }, - { id: "notifications", label: "Notifications", href: "/settings/notifications", icon: Bell }, + { + id: "notifications", + label: "Notifications", + href: "/settings/notifications", + icon: Bell + }, { id: "knots", label: "Knots", href: "/settings/knots", icon: Volleyball }, { id: "spindles", label: "Spindles", href: "/settings/spindles", icon: Spool }, { id: "sites", label: "Sites", href: "/settings/sites", icon: Globe } @@ -34,7 +40,16 @@
- {@render children()} + + + {@render children()} +