Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
94 linesCodeBlameRaw
1import { type VariantProps, cva } from "class-variance-authority";
2import type { ComponentProps, ReactNode } from "react";
3import { Link } from "react-router";
4
5import { cn } from "../../lib/cn";
6import { SoonPill } from "./index";
7import { TabStrip } from "./tab-strip";
8
9// Navigation tabs: links that say which page or view you are on, in a row
10// that scrolls sideways on a phone (./tab-strip). shadcn's Tabs switch panels
11// on one page; these go to other addresses. Two variants, one per level:
12//
13// `line` is the primary row along a header's bottom border: the current tab
14// is underlined on that border, and the row sits one pixel into it so the
15// two lines are one. `pill` is a secondary row inside a page's content, for
16// the views of one section: the current view a quiet raised chip, nothing
17// drawn outside the tab's own box, so the strip's overflow never cuts it.
18
19export const navTabsVariants = cva("", {
20 variants: {
21 variant: {
22 // Over the border: the strip's bottom edge is the header's line. The
23 // first tab's text lines up with the page's gutter, its underline a
24 // hair past it, and on a phone either row runs to the screen's edges
25 // as it scrolls.
26 line: "-mx-4 -mb-px gap-4 px-3 sm:-mx-1 sm:px-0",
27 pill: "-mx-4 gap-1 px-4 sm:mx-0 sm:px-0",
28 },
29 },
30 defaultVariants: { variant: "line" },
31});
32
33export const navTabVariants = cva(
34 "flex items-center gap-1.5 text-sm whitespace-nowrap outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset [&_svg]:shrink-0",
35 {
36 variants: {
37 variant: {
38 line: "h-11 border-b-2 border-transparent px-1 hover:border-line-strong aria-[current=page]:border-accent",
39 pill: "h-8 rounded-md px-3 hover:bg-raised/60 aria-[current=page]:bg-raised",
40 },
41 soon: {
42 true: "text-faint hover:text-muted",
43 false: "text-muted hover:text-fg aria-[current=page]:font-medium aria-[current=page]:text-fg",
44 },
45 },
46 defaultVariants: { variant: "line", soon: false },
47 },
48);
49
50type Variant = NonNullable<VariantProps<typeof navTabsVariants>["variant"]>;
51
52/** A row of navigation tabs: `line` along a header's border, `pill` within a page. */
53export function NavTabs({ label, variant, className, children }: { label: string; variant?: Variant; className?: string; children: ReactNode }) {
54 return (
55 <TabStrip label={label} className={cn(navTabsVariants({ variant }), className)}>
56 {children}
57 </TabStrip>
58 );
59}
60
61/**
62 * One tab: a link, current when `current` says so (not by its address,
63 * which would also mark a section's first page current under every other).
64 * `count` shows beside the label when there are any; `soon` marks a tab
65 * whose page is still coming.
66 */
67export function NavTab({
68 current,
69 variant,
70 soon,
71 count,
72 className,
73 children,
74 ...props
75}: ComponentProps<typeof Link> & VariantProps<typeof navTabVariants> & { current?: boolean; count?: number }) {
76 return (
77 <Link
78 prefetch="intent"
79 aria-current={current ? "page" : undefined}
80 data-active={current || undefined}
81 className={cn(navTabVariants({ variant, soon }), className)}
82 {...props}
83 >
84 {children}
85 {count != null && count > 0 && <NavTabCount>{count}</NavTabCount>}
86 {soon && <SoonPill />}
87 </Link>
88 );
89}
90
91/** How many there are, beside a tab's label. */
92function NavTabCount({ children }: { children: ReactNode }) {
93 return <span className="rounded-full bg-raised px-1.5 text-xs leading-5 font-normal text-muted tabular-nums">{children}</span>;
94}