Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ These rules hold for every WinLab app. `bun run tokens:check` enforces the parts

## App shell

Every WinLab app is framed by four fixed corners, 24px in from the viewport. `app-shell` draws them. By default each corner has the job below; an app that needs something else in a corner replaces it through `corners`, built from `CornerLink` and `CornerTip` so it keeps the corner's type, color and tips.
Every WinLab app is framed by four fixed corners, 24px in from the viewport. `app-shell` draws them. By default each corner has the job below; an app that needs something else in a corner replaces it through `corners`, built from `CornerLink`, `CornerTip` and `cornerLinkClassName` (for a corner item that is not a link) so it keeps the corner's type, color and tips.

| Corner | Default job | Default content |
|--------|-----|---------|
| Top left | Where you are | Breadcrumb: the lab (portal home), then the app (its home) |
| Top right | Where you can go | The app's pages, the current one in `foreground`; last, the account: "登入" when signed out, the member's name when signed in |
| Top right | Where you can go | The app's pages, the current one in `foreground`; last, the account: "登入" when signed out, the member's name when signed in (a link to their page, or a menu with items such as 登出) |
| Bottom left | Which build | The short commit of this build, linking to its CD run, with the build time (Asia/Taipei) as its tip |
| Bottom right | Whose it is | © year; its tip says NYCU WinLab |

Expand All @@ -20,7 +20,7 @@ Every WinLab app is framed by four fixed corners, 24px in from the viewport. `ap
- Every corner item has a tip (`tip` is required in `app-shell`, so a missing one fails the type check) that says what its label leaves out (where a link goes, what a page holds, who owns the site). A tip never repeats the label. Tips open at once, toward the page, lined up with the corner's outer edge; touch never opens them, so nothing depends on one.
- Corner text is `text-body` in `muted-foreground`, links turn `foreground` on hover and when current.
- Content that scrolls under the corners fades into a 64px band of frosted glass at the top and bottom edges.
- No menu button: an app keeps its pages few enough to fit beside the breadcrumb on a phone.
- Below `md` the pages fold into one menu button (頁面選單) holding the same links; the breadcrumb and the account stay. Keep an app to about five pages per area, so the wide layout still fits on one line. An app with a second area (such as `/admin`) shows that area's pages there instead, with one more breadcrumb.

## Page layouts

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ npx shadcn@latest add @winlab/button
| `number-ticker` | `registry:ui` | A whole number whose digits roll to their new value |
| `progress` | `registry:ui` | How much of a whole is used: an 8px muted track with a primary fill |
| `chart` | `registry:ui` | Recharts in WinLab colors: series take `primary`, `muted-foreground` or a state color; inverted tooltip and a legend |
| `app-shell` | `registry:block` | The four corners of every WinLab app and the page layouts `column`, `spotlight` and `wide`; set `NEXT_PUBLIC_BUILD_SHA`, `_TIME` and `_URL` at build for the version corner |
| `app-shell` | `registry:block` | The four corners of every WinLab app (pages fold into a menu on narrow screens; the account can be a link or a menu) and the page layouts `column`, `spotlight` and `wide`; set `NEXT_PUBLIC_BUILD_SHA`, `_TIME` and `_URL` at build for the version corner |
| `confirm-dialog` | `registry:block` | Asks before an action runs; the confirm button names the verb, both buttons lock while it runs, and it closes only when the action finishes |
| `form-dialog` | `registry:block` | A short form in a dialog; `FormField` puts a label over its control, fields and buttons lock while it submits, and it closes only when the submit finishes |
| `empty-state` | `registry:block` | What a list shows with no rows: "還沒有{noun}" or "找不到符合「{query}」的{noun}", with the next action; `TableEmpty` is the same sentence as a table row |
Expand Down
2 changes: 1 addition & 1 deletion app/_docs/demos/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export function Demo() {
return (
<p className="text-muted-foreground">
左上麵包屑、右上導覽與帳號、左下版本、右下版權。
左上麵包屑、右上導覽與帳號(窄螢幕收成選單)、左下版本、右下版權。
</p>
)
}
9 changes: 6 additions & 3 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -960,13 +960,16 @@
"name": "app-shell",
"type": "registry:block",
"title": "App Shell",
"description": "The four corners of a WinLab app: breadcrumb, page nav and account, build version, copyright year.",
"description": "The four corners of a WinLab app: breadcrumb, page nav (a menu on narrow screens) and account, build version, copyright year.",
"dependencies": [
"cn"
"cn",
"lucide-react"
],
"registryDependencies": [
"https://ui.winlab.tw/r/utils.json",
"https://ui.winlab.tw/r/tooltip.json"
"https://ui.winlab.tw/r/tooltip.json",
"https://ui.winlab.tw/r/popover.json",
"https://ui.winlab.tw/r/dropdown-menu.json"
],
"files": [
{
Expand Down
182 changes: 146 additions & 36 deletions registry/winlab/blocks/app-shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { MenuIcon } from "lucide-react"
import { cn } from "@/registry/winlab/lib/utils"

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/winlab/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/winlab/ui/popover"

import {
Tooltip,
TooltipContent,
Expand All @@ -17,14 +29,18 @@ import {
// it is); it never repeats the label.
type Crumb = { label: string; href: string; tip: string }
type NavItem = { label: string; href: string; tip: string }
// Signed in, the account can open a menu instead of a page: the member's
// name, then items such as 登出 (DropdownMenuItem children).
type AccountMenu = { label: string; tip: string; menu: React.ReactNode }

// The chrome of every WinLab app is its four corners. By default each corner
// has one job, filled from the props below; an app that needs something else
// in a corner passes it in `corners`, built from CornerLink and CornerTip so
// it keeps the corner's look.
// top left where you are: breadcrumb back to the lab and the app
// top right where you can go: this app's pages, then the account (登入
// when signed out, the member's name when signed in)
// when signed out, the member's name when signed in). Below
// md the pages fold into one menu button; the account stays.
// bottom left which build: the commit this build came from
// bottom right the copyright year; the tip names the owner
// Every item can carry a tip, opening toward the page and lined up with the
Expand All @@ -45,9 +61,9 @@ function AppShell({
}: {
breadcrumb?: Crumb[]
nav?: NavItem[]
/** Last in the top right: { label: "登入", href: "/sign-in" } signed out,
* the member's name linking to their page signed in. */
account?: NavItem
/** Last in the top right: { label: "登入", href: "/sign-in" } signed out;
* signed in, the member's name linking to their page, or opening `menu`. */
account?: NavItem | AccountMenu
corners?: Partial<
Record<
"topLeft" | "topRight" | "bottomLeft" | "bottomRight",
Expand Down Expand Up @@ -90,7 +106,10 @@ function AppShell({
href={crumb.href}
prefetch
aria-current={last ? "page" : undefined}
className={cn(cornerLink, last && "text-foreground")}
className={cn(
cornerLinkClassName,
last && "text-foreground"
)}
>
{crumb.label}
</Link>
Expand All @@ -103,30 +122,12 @@ function AppShell({
</header>

<div className="fixed top-6 right-6 z-50 flex items-center gap-4">
{corners?.topRight ??
((nav.length > 0 || account) && (
<nav aria-label="頁面" className="flex items-center gap-4">
{[...nav, ...(account ? [account] : [])].map((item) => (
<CornerTip key={item.href} tip={item.tip} corner="top-right">
<Link
href={item.href}
// Pages are per member and dynamic, so Next only prefetches
// them when asked; then switching between them needs no
// round trip. The account link can be /sign-in, a route
// that starts a sign-in, so it is never prefetched.
prefetch={item !== account}
aria-current={isCurrent(item.href) ? "page" : undefined}
className={cn(
cornerLink,
isCurrent(item.href) && "text-foreground"
)}
>
{item.label}
</Link>
</CornerTip>
))}
</nav>
))}
{corners?.topRight ?? (
<>
{nav.length > 0 && <PageNav nav={nav} isCurrent={isCurrent} />}
{account && <Account account={account} isCurrent={isCurrent} />}
</>
)}
</div>

<div className="fixed bottom-6 left-6 z-50 flex items-center gap-4">
Expand All @@ -136,7 +137,10 @@ function AppShell({
<div className="fixed right-6 bottom-6 z-50 flex items-center gap-4">
{corners?.bottomRight ?? (
<CornerTip tip="NYCU WinLab" corner="bottom-right">
<span tabIndex={0} className={cn(cornerLink, "cursor-default")}>
<span
tabIndex={0}
className={cn(cornerLinkClassName, "cursor-default")}
>
© {new Date().getFullYear()}
</span>
</CornerTip>
Expand All @@ -158,6 +162,108 @@ function AppShell({
)
}

// Wide screens list the pages; narrow ones fold them into a menu of the same
// links, so an app with five pages still fits beside the breadcrumb.
function PageNav({
nav,
isCurrent,
}: {
nav: NavItem[]
isCurrent: (href: string) => boolean
}) {
const [open, setOpen] = React.useState(false)
return (
<>
<nav aria-label="頁面" className="hidden items-center gap-4 md:flex">
{nav.map((item) => (
<CornerTip key={item.href} tip={item.tip} corner="top-right">
<Link
href={item.href}
// Pages are per member and dynamic, so Next only prefetches
// them when asked; then switching between them needs no
// round trip.
prefetch
aria-current={isCurrent(item.href) ? "page" : undefined}
className={cn(
cornerLinkClassName,
isCurrent(item.href) && "text-foreground"
)}
>
{item.label}
</Link>
</CornerTip>
))}
</nav>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
aria-label="頁面選單"
className={cn(cornerLinkClassName, "cursor-pointer md:hidden")}
>
<MenuIcon className="size-5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1">
<nav aria-label="頁面" className="flex flex-col">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
aria-current={isCurrent(item.href) ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
"flex h-10 items-center rounded-control px-3 text-muted-foreground transition-colors duration-state outline-none hover:bg-muted hover:text-foreground focus-visible:bg-muted",
isCurrent(item.href) && "text-foreground"
)}
>
{item.label}
</Link>
))}
</nav>
</PopoverContent>
</Popover>
</>
)
}

// The account is a link (登入, or the member's page) or a menu under the
// member's name. It is never prefetched: /sign-in can start a sign-in.
function Account({
account,
isCurrent,
}: {
account: NavItem | AccountMenu
isCurrent: (href: string) => boolean
}) {
if ("menu" in account) {
return (
<DropdownMenu>
<CornerTip tip={account.tip} corner="top-right">
<DropdownMenuTrigger
className={cn(cornerLinkClassName, "max-w-40 cursor-pointer")}
>
<span className="truncate">{account.label}</span>
</DropdownMenuTrigger>
</CornerTip>
<DropdownMenuContent align="end">{account.menu}</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<CornerTip tip={account.tip} corner="top-right">
<Link
href={account.href}
prefetch={false}
aria-current={isCurrent(account.href) ? "page" : undefined}
className={cn(
cornerLinkClassName,
isCurrent(account.href) && "text-foreground"
)}
>
{account.label}
</Link>
</CornerTip>
)
}

type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"

function CornerTip({
Expand All @@ -183,7 +289,8 @@ function CornerTip({
)
}

const cornerLink =
// The look of a corner item, for custom corner content that is not a link.
const cornerLinkClassName =
"relative inline-flex h-6 items-center text-muted-foreground transition-colors duration-state outline-none after:absolute after:-inset-2 hover:text-foreground focus-visible:text-foreground"

// Which build is running: the short commit, linking to the CD run that built
Expand All @@ -194,17 +301,20 @@ function BuildVersion() {
const time = process.env.NEXT_PUBLIC_BUILD_TIME
const url = process.env.NEXT_PUBLIC_BUILD_URL
if (!sha) {
return <span className={cornerLink}>dev</span>
return <span className={cornerLinkClassName}>dev</span>
}
const label = <span className="font-mono">{sha}</span>
return (
<CornerTip tip={time ? `建置於 ${time}` : undefined} corner="bottom-left">
{url ? (
<a href={url} className={cornerLink}>
<a href={url} className={cornerLinkClassName}>
{label}
</a>
) : (
<span tabIndex={0} className={cn(cornerLink, "cursor-default")}>
<span
tabIndex={0}
className={cn(cornerLinkClassName, "cursor-default")}
>
{label}
</span>
)}
Expand All @@ -217,7 +327,7 @@ function CornerLink({
className,
...props
}: React.ComponentProps<typeof Link>) {
return <Link className={cn(cornerLink, className)} {...props} />
return <Link className={cn(cornerLinkClassName, className)} {...props} />
}

export { AppShell, CornerLink, CornerTip }
export { AppShell, CornerLink, CornerTip, cornerLinkClassName }
Loading