From fbc728a14cddcea92e58212168dbd60d1347b5aa Mon Sep 17 00:00:00 2001 From: Loki Date: Fri, 9 Oct 2026 21:27:18 +0800 Subject: [PATCH] feat: focus and quiet: focus, avatar-stack, countdown, expand-row, number-ticker, spring motion Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 22 ++- app/_docs/demos/avatar-stack.tsx | 20 ++ app/_docs/demos/countdown.tsx | 13 ++ app/_docs/demos/expand-row.tsx | 51 +++++ app/_docs/demos/focus.tsx | 44 +++++ app/_docs/demos/index.ts | 10 + app/_docs/demos/number-ticker.tsx | 26 +++ app/_docs/docs.ts | 35 ++++ app/globals.css | 94 ++++++++++ registry.json | 177 ++++++++++++++++++ .../blocks/avatar-stack/avatar-stack.tsx | 88 +++++++++ .../winlab/blocks/countdown/countdown.tsx | 67 +++++++ .../winlab/blocks/expand-row/expand-row.tsx | 51 +++++ registry/winlab/blocks/focus/focus.tsx | 68 +++++++ registry/winlab/ui/number-ticker.tsx | 44 +++++ 15 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 app/_docs/demos/avatar-stack.tsx create mode 100644 app/_docs/demos/countdown.tsx create mode 100644 app/_docs/demos/expand-row.tsx create mode 100644 app/_docs/demos/focus.tsx create mode 100644 app/_docs/demos/number-ticker.tsx create mode 100644 registry/winlab/blocks/avatar-stack/avatar-stack.tsx create mode 100644 registry/winlab/blocks/countdown/countdown.tsx create mode 100644 registry/winlab/blocks/expand-row/expand-row.tsx create mode 100644 registry/winlab/blocks/focus/focus.tsx create mode 100644 registry/winlab/ui/number-ticker.tsx diff --git a/DESIGN.md b/DESIGN.md index 1236ad5..6c846e1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -36,6 +36,16 @@ Use `wide` only when the content is a grid that loses meaning when squeezed; tex In a spotlight, the title and description center above the focal content; text that is the focus itself stays left-aligned when it runs to more than one line. +## Focus and quiet + +Every app page has one loud thing and keeps the rest quiet. + +- **The focus** is what a member opened the page for: the next meeting, today's lunch order, what waits on their signature. It sits right under the page header, in the `focus` block, and nowhere else on the page. +- Only the focus may use the primary color for text (`FocusHighlight`), the spring (`duration-spring`, `animate-rise`) and the large type of `FocusTitle`. Numbers in it roll (`number-ticker`, `countdown`); people in it show as an `avatar-stack`. +- **Everything else is quiet**: lists in `text-body`, secondary facts in `muted-foreground`, motion at `duration-state` / `duration-overlay`. Lists may arrive with `stagger-rise`. +- A page with nothing pressing (a settings page, a list of records) has no focus. Do not invent one. +- A row that has more to say opens in place (`expand-row`) instead of sending the member to another page; editing still happens in a dialog. + ## Two layers The interface is flat and has exactly two layers. @@ -126,16 +136,24 @@ Focus follows the component's own color. `--ring` is a neutral gray, so inputs, ## Motion -Two durations, both ease-out: +Quiet things move briefly and ease out; the page's one focus moves on a spring. | Utility | Duration | Use | |---------|----------|-----| | `duration-state` | 150ms | A control changing state: hover, focus, check, switch, the tab highlight sliding | | `duration-overlay` | 200ms | Something appearing or leaving: dialogs, menus, popovers, tooltips, a collapsible opening | +| `duration-spring` | 500ms, overshooting 10% | Only what the page is about: the `focus` block, digits rolling, an avatar stack spreading, a row opening in place | + +| Utility | What it does | +|---------|--------------| +| `animate-rise` | The focus rises 8px into place on the spring when the page loads | +| `stagger-rise` | On a list: its rows rise one after another, 30ms apart, in 200ms each, so a list arrives as a sequence and not as one block | - Every animation runs the same way in both directions: what zooms in zooms out, what fades in fades out, at the same duration. - Overlays fade and zoom from where they open; a select menu that sits over its trigger only fades, opening and closing. - Menu rows highlight instantly, so keyboard navigation never lags. -- When the system asks for reduced motion (`prefers-reduced-motion: reduce`), every zoom and slide is dropped and the fades stay; sliding highlights and height animations jump instead. +- The spring is for the focus only. A spring on every button makes the page loud everywhere, which is the same as nowhere. +- When the system asks for reduced motion (`prefers-reduced-motion: reduce`), every zoom and slide is dropped and the fades stay; rising becomes fading, digits change without rolling, sliding highlights and height animations jump instead. - `tokens:check` rejects other durations (`duration-300`), easing classes and delays. - Toasts keep sonner's own timing, which also follows reduced motion. + diff --git a/app/_docs/demos/avatar-stack.tsx b/app/_docs/demos/avatar-stack.tsx new file mode 100644 index 0000000..c82a115 --- /dev/null +++ b/app/_docs/demos/avatar-stack.tsx @@ -0,0 +1,20 @@ +import { AvatarStack } from "@/registry/winlab/blocks/avatar-stack/avatar-stack" + +const people = [ + "教授", + "博士生", + "碩士生", + "專題生", + "助理", + "訪問學者", + "校友", +].map((name, index) => ({ id: String(index), name })) + +export function Demo() { + return ( +
+ + +
+ ) +} diff --git a/app/_docs/demos/countdown.tsx b/app/_docs/demos/countdown.tsx new file mode 100644 index 0000000..7a5497a --- /dev/null +++ b/app/_docs/demos/countdown.tsx @@ -0,0 +1,13 @@ +import { Countdown } from "@/registry/winlab/blocks/countdown/countdown" + +const soon = new Date(Date.now() + 42 * 60 * 1000) +const later = new Date(Date.now() + (2 * 1440 + 5 * 60) * 60 * 1000) + +export function Demo() { + return ( +
+ + +
+ ) +} diff --git a/app/_docs/demos/expand-row.tsx b/app/_docs/demos/expand-row.tsx new file mode 100644 index 0000000..9ffacef --- /dev/null +++ b/app/_docs/demos/expand-row.tsx @@ -0,0 +1,51 @@ +"use client" + +import { PencilIcon } from "lucide-react" + +import { AvatarStack } from "@/registry/winlab/blocks/avatar-stack/avatar-stack" +import { ExpandRow } from "@/registry/winlab/blocks/expand-row/expand-row" +import { Button } from "@/registry/winlab/ui/button" + +const weeks = [ + { date: "10 月 19 日", who: "博士生", what: "NetLLM (SIGCOMM'24)" }, + { date: "11 月 2 日", who: "碩士生", what: "ORANSlice (MobiCom'24)" }, + { date: "11 月 9 日", who: "專題生", what: "XRP (OSDI'22)" }, +] + +export function Demo() { + return ( + + ) +} diff --git a/app/_docs/demos/focus.tsx b/app/_docs/demos/focus.tsx new file mode 100644 index 0000000..1607bbb --- /dev/null +++ b/app/_docs/demos/focus.tsx @@ -0,0 +1,44 @@ +"use client" + +import { AvatarStack } from "@/registry/winlab/blocks/avatar-stack/avatar-stack" +import { Countdown } from "@/registry/winlab/blocks/countdown/countdown" +import { + Focus, + FocusHighlight, + FocusLabel, + FocusMeta, + FocusTitle, +} from "@/registry/winlab/blocks/focus/focus" +import { PageHeader } from "@/registry/winlab/blocks/page-header/page-header" + +const askers = [ + { id: "1", name: "博士生" }, + { id: "2", name: "碩士生" }, + { id: "3", name: "專題生" }, +] + +// Three days and a bit from whenever the page is opened. +const next = new Date(Date.now() + (3 * 1440 + 19 * 60 + 37) * 60 * 1000) + +export function Demo() { + return ( +
+ + + 下一場 + + + + {" "} + 後,碩士生報告 + +

Efficient Memory Management for LLM Serving (SOSP'23)

+ + + 提問 + EC 411 15:30 + +
+
+ ) +} diff --git a/app/_docs/demos/index.ts b/app/_docs/demos/index.ts index 764df94..9a9f761 100644 --- a/app/_docs/demos/index.ts +++ b/app/_docs/demos/index.ts @@ -38,6 +38,11 @@ import { Demo as Separator } from "@/app/_docs/demos/separator" import { Demo as Collapsible } from "@/app/_docs/demos/collapsible" import { Demo as Skeleton } from "@/app/_docs/demos/skeleton" import { Demo as Sonner } from "@/app/_docs/demos/sonner" +import { Demo as FocusDemo } from "@/app/_docs/demos/focus" +import { Demo as AvatarStackDemo } from "@/app/_docs/demos/avatar-stack" +import { Demo as CountdownDemo } from "@/app/_docs/demos/countdown" +import { Demo as ExpandRowDemo } from "@/app/_docs/demos/expand-row" +import { Demo as NumberTicker } from "@/app/_docs/demos/number-ticker" export const demos: Record = { colors: Colors, @@ -78,4 +83,9 @@ export const demos: Record = { collapsible: Collapsible, skeleton: Skeleton, sonner: Sonner, + focus: FocusDemo, + "avatar-stack": AvatarStackDemo, + countdown: CountdownDemo, + "expand-row": ExpandRowDemo, + "number-ticker": NumberTicker, } diff --git a/app/_docs/demos/number-ticker.tsx b/app/_docs/demos/number-ticker.tsx new file mode 100644 index 0000000..0b00e0f --- /dev/null +++ b/app/_docs/demos/number-ticker.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" + +import { Button } from "@/registry/winlab/ui/button" +import { NumberTicker } from "@/registry/winlab/ui/number-ticker" + +export function Demo() { + const [value, setValue] = React.useState(128) + return ( +
+

+ NT$ +

+
+ + +
+
+ ) +} diff --git a/app/_docs/docs.ts b/app/_docs/docs.ts index 630fb49..17796f3 100644 --- a/app/_docs/docs.ts +++ b/app/_docs/docs.ts @@ -54,6 +54,34 @@ export const docs: Doc[] = [ description: "清單沒有資料或搜尋沒有結果時,統一的一句話與下一步。", item: "empty-state", }, + { + group: "區塊", + slug: "focus", + title: "焦點", + description: "每頁一個大聲的地方,其他都安靜:標籤、標題與重點、一列資訊。", + item: "focus", + }, + { + group: "區塊", + slug: "avatar-stack", + title: "頭像疊", + description: "多個人放在一個人的位置,指到時展開。", + item: "avatar-stack", + }, + { + group: "區塊", + slug: "countdown", + title: "倒數", + description: "距離某個時間還有多久,到分鐘,數字會滾動。", + item: "countdown", + }, + { + group: "區塊", + slug: "expand-row", + title: "展開列", + description: "清單的一列原地展開細節,不換頁也不開對話框。", + item: "expand-row", + }, { group: "區塊", slug: "page-header", @@ -244,6 +272,13 @@ export const docs: Doc[] = [ description: "成員頭像,沒有圖時顯示名字的第一個字。", item: "avatar", }, + { + group: "元件", + slug: "number-ticker", + title: "數字滾動", + description: "整數變動時,每一位數字滾到新的值。", + item: "number-ticker", + }, { group: "元件", slug: "tooltip", diff --git a/app/globals.css b/app/globals.css index 2eb5837..8307e83 100644 --- a/app/globals.css +++ b/app/globals.css @@ -76,6 +76,94 @@ transition-timing-function: cubic-bezier(0, 0, 0.2, 1); } +/* The page's one focus moves on a spring: 500ms, overshooting 10%. */ +@utility duration-spring { + --tw-duration: 500ms; + transition-duration: 500ms; + --tw-ease: linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1); + transition-timing-function: linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1); +} + +/* Arriving on the page: the focus rises on the spring, a list's rows + rise one after another, 30ms apart, quietly. */ +@keyframes rise { + from { + opacity: 0; + translate: 0 0.5rem; + } +} + +@keyframes appear { + from { + opacity: 0; + } +} + +@utility animate-rise { + animation: rise 500ms linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1) both; +} + +@utility stagger-rise { + & > * { + animation: rise 200ms cubic-bezier(0, 0, 0.2, 1) both; + } + & > :nth-child(2) { + animation-delay: 30ms; + } + & > :nth-child(3) { + animation-delay: 60ms; + } + & > :nth-child(4) { + animation-delay: 90ms; + } + & > :nth-child(5) { + animation-delay: 120ms; + } + & > :nth-child(6) { + animation-delay: 150ms; + } + & > :nth-child(7) { + animation-delay: 180ms; + } + & > :nth-child(8) { + animation-delay: 210ms; + } + & > :nth-child(n + 9) { + animation-delay: 240ms; + } +} + +/* A number-ticker digit column; data-digit picks the row. */ +@utility digit-roll { + transition: translate 500ms linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1); + &[data-digit="1"] { + translate: 0 -10%; + } + &[data-digit="2"] { + translate: 0 -20%; + } + &[data-digit="3"] { + translate: 0 -30%; + } + &[data-digit="4"] { + translate: 0 -40%; + } + &[data-digit="5"] { + translate: 0 -50%; + } + &[data-digit="6"] { + translate: 0 -60%; + } + &[data-digit="7"] { + translate: 0 -70%; + } + &[data-digit="8"] { + translate: 0 -80%; + } + &[data-digit="9"] { + translate: 0 -90%; + } +} /* Reduced motion keeps the fades and drops every zoom and slide. */ @media (prefers-reduced-motion: reduce) { *, ::before, ::after { @@ -86,6 +174,12 @@ --tw-exit-translate-x: 0 !important; --tw-exit-translate-y: 0 !important; } + .animate-rise, .stagger-rise > * { + animation-name: appear; + } + .digit-roll { + transition: none; + } } /* Menus with p-1 sit 4px outside their trigger on both sides. */ diff --git a/registry.json b/registry.json index 967d144..753b3a7 100644 --- a/registry.json +++ b/registry.json @@ -65,6 +65,85 @@ "--tw-ease": "cubic-bezier(0, 0, 0.2, 1)", "transition-timing-function": "cubic-bezier(0, 0, 0.2, 1)" }, + "@utility duration-spring": { + "--tw-duration": "500ms", + "transition-duration": "500ms", + "--tw-ease": "linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1)", + "transition-timing-function": "linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1)" + }, + "@keyframes rise": { + "from": { + "opacity": "0", + "translate": "0 0.5rem" + } + }, + "@keyframes appear": { + "from": { + "opacity": "0" + } + }, + "@utility animate-rise": { + "animation": "rise 500ms linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1) both" + }, + "@utility stagger-rise": { + "& > *": { + "animation": "rise 200ms cubic-bezier(0, 0, 0.2, 1) both" + }, + "& > :nth-child(2)": { + "animation-delay": "30ms" + }, + "& > :nth-child(3)": { + "animation-delay": "60ms" + }, + "& > :nth-child(4)": { + "animation-delay": "90ms" + }, + "& > :nth-child(5)": { + "animation-delay": "120ms" + }, + "& > :nth-child(6)": { + "animation-delay": "150ms" + }, + "& > :nth-child(7)": { + "animation-delay": "180ms" + }, + "& > :nth-child(8)": { + "animation-delay": "210ms" + }, + "& > :nth-child(n + 9)": { + "animation-delay": "240ms" + } + }, + "@utility digit-roll": { + "transition": "translate 500ms linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1)", + "&[data-digit=\"1\"]": { + "translate": "0 -10%" + }, + "&[data-digit=\"2\"]": { + "translate": "0 -20%" + }, + "&[data-digit=\"3\"]": { + "translate": "0 -30%" + }, + "&[data-digit=\"4\"]": { + "translate": "0 -40%" + }, + "&[data-digit=\"5\"]": { + "translate": "0 -50%" + }, + "&[data-digit=\"6\"]": { + "translate": "0 -60%" + }, + "&[data-digit=\"7\"]": { + "translate": "0 -70%" + }, + "&[data-digit=\"8\"]": { + "translate": "0 -80%" + }, + "&[data-digit=\"9\"]": { + "translate": "0 -90%" + } + }, "@media (prefers-reduced-motion: reduce)": { "*, ::before, ::after": { "--tw-enter-scale": "1 !important", @@ -73,6 +152,12 @@ "--tw-enter-translate-y": "0 !important", "--tw-exit-translate-x": "0 !important", "--tw-exit-translate-y": "0 !important" + }, + ".animate-rise, .stagger-rise > *": { + "animation-name": "appear" + }, + ".digit-roll": { + "transition": "none" } } }, @@ -540,6 +625,24 @@ "https://ui.winlab.tw/r/utils.json" ] }, + { + "name": "number-ticker", + "type": "registry:ui", + "title": "Number Ticker", + "description": "A whole number whose digits roll to their new value.", + "dependencies": [ + "cn" + ], + "files": [ + { + "path": "registry/winlab/ui/number-ticker.tsx", + "type": "registry:ui" + } + ], + "registryDependencies": [ + "https://ui.winlab.tw/r/utils.json" + ] + }, { "name": "tooltip", "type": "registry:ui", @@ -881,6 +984,80 @@ "type": "registry:component" } ] + }, + { + "name": "focus", + "type": "registry:block", + "title": "Focus", + "description": "The one loud thing on a page, under its header: a label, a title with a highlight, and a row of facts.", + "dependencies": [ + "cn" + ], + "registryDependencies": [ + "https://ui.winlab.tw/r/utils.json" + ], + "files": [ + { + "path": "registry/winlab/blocks/focus/focus.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "avatar-stack", + "type": "registry:block", + "title": "Avatar Stack", + "description": "Several people in the space of one; the avatars spread apart when pointed at.", + "dependencies": [ + "cn" + ], + "registryDependencies": [ + "https://ui.winlab.tw/r/utils.json", + "https://ui.winlab.tw/r/avatar.json", + "https://ui.winlab.tw/r/tooltip.json" + ], + "files": [ + { + "path": "registry/winlab/blocks/avatar-stack/avatar-stack.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "countdown", + "type": "registry:block", + "title": "Countdown", + "description": "Time left until a moment, to the minute, with rolling digits.", + "dependencies": [], + "registryDependencies": [ + "https://ui.winlab.tw/r/number-ticker.json" + ], + "files": [ + { + "path": "registry/winlab/blocks/countdown/countdown.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "expand-row", + "type": "registry:block", + "title": "Expand Row", + "description": "A list row that opens in place to show its details.", + "dependencies": [ + "@base-ui/react", + "lucide-react", + "cn" + ], + "registryDependencies": [ + "https://ui.winlab.tw/r/utils.json" + ], + "files": [ + { + "path": "registry/winlab/blocks/expand-row/expand-row.tsx", + "type": "registry:component" + } + ] } ] } diff --git a/registry/winlab/blocks/avatar-stack/avatar-stack.tsx b/registry/winlab/blocks/avatar-stack/avatar-stack.tsx new file mode 100644 index 0000000..006b75b --- /dev/null +++ b/registry/winlab/blocks/avatar-stack/avatar-stack.tsx @@ -0,0 +1,88 @@ +"use client" + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/registry/winlab/ui/avatar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/winlab/ui/tooltip" +import { cn } from "@/registry/winlab/lib/utils" + +export type StackPerson = { id: string; name: string; image?: string | null } + +// The first character a name is known by: 詹 for 詹詠翔, Z for Zhan. +const initial = (name: string) => Array.from(name.trim())[0] ?? "?" + +// Several people in the space of one: avatars overlap, and spread apart on +// the spring when pointed at or focused. Each names its person in a tooltip. +// Past max, the rest collapse into a +N. +function AvatarStack({ + people, + max = 5, + className, +}: { + people: StackPerson[] + max?: number + className?: string +}) { + const shown = people.slice(0, max) + const rest = people.slice(max) + return ( +
+ {shown.map((person, index) => ( + + 0 && + "-ml-3 group-focus-within/stack:ml-1 group-hover/stack:ml-1" + )} + /> + } + > + + {person.image && } + {initial(person.name)} + + + {person.name} + + ))} + {rest.length > 0 && ( + + person.name).join("、")} + className="-ml-3 rounded-full transition-all duration-spring outline-none group-focus-within/stack:ml-1 group-hover/stack:ml-1 focus-visible:ring-3 focus-visible:ring-ring/50" + /> + } + > + + + +{rest.length} + + + + + {rest.map((person) => person.name).join("、")} + + + )} +
+ ) +} + +export { AvatarStack } diff --git a/registry/winlab/blocks/countdown/countdown.tsx b/registry/winlab/blocks/countdown/countdown.tsx new file mode 100644 index 0000000..2d2356a --- /dev/null +++ b/registry/winlab/blocks/countdown/countdown.tsx @@ -0,0 +1,67 @@ +"use client" + +import * as React from "react" + +import { NumberTicker } from "@/registry/winlab/ui/number-ticker" + +const MINUTE = 60 * 1000 +const TICK = 15 * 1000 + +// The clock, read every 15 seconds; null on the server, so the first client +// render matches it and nothing ticks until mounted. +function subscribe(update: () => void) { + const timer = setInterval(update, TICK) + return () => clearInterval(timer) +} +const readClock = () => Math.floor(Date.now() / TICK) * TICK +const serverClock = () => null + +function parts(ms: number) { + const minutes = Math.max(0, Math.ceil(ms / MINUTE)) + return { + days: Math.floor(minutes / 1440), + hours: Math.floor((minutes % 1440) / 60), + minutes: minutes % 60, + } +} + +// Time left until a moment, to the minute: "3 天 19 小時 37 分". Units that +// are zero at the front drop out; the digits roll as the minutes pass. Once +// the moment arrives it shows children instead (for example "進行中"). +function Countdown({ + to, + children = "現在", +}: { + /** The moment, as an ISO string with its offset or a Date. */ + to: string | Date + children?: React.ReactNode +}) { + const target = new Date(to).getTime() + const now = React.useSyncExternalStore(subscribe, readClock, serverClock) + + const left = target - (now ?? target - 1) + if (now !== null && left <= 0) return <>{children} + const { days, hours, minutes } = parts(left) + const units = [ + { value: days, unit: "天", show: days > 0 }, + { value: hours, unit: "小時", show: days > 0 || hours > 0 }, + { value: minutes, unit: "分", show: true }, + ].filter((part) => part.show) + + return ( + + {now === null ? ( + 0 分 + ) : ( + units.map((part) => ( + + + {part.unit} + + )) + )} + + ) +} + +export { Countdown } diff --git a/registry/winlab/blocks/expand-row/expand-row.tsx b/registry/winlab/blocks/expand-row/expand-row.tsx new file mode 100644 index 0000000..c66cdd3 --- /dev/null +++ b/registry/winlab/blocks/expand-row/expand-row.tsx @@ -0,0 +1,51 @@ +"use client" + +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" +import { ChevronDownIcon } from "lucide-react" +import * as React from "react" + +import { cn } from "@/registry/winlab/lib/utils" + +// A list row that opens in place to show more, instead of going to another +// page or a dialog. The summary is the row as the list shows it; the details +// unfold under it on the spring. Row actions sit beside the summary, outside +// the toggle, so pressing one never opens the row. +function ExpandRow({ + summary, + actions, + children, + defaultOpen, + className, +}: { + summary: React.ReactNode + actions?: React.ReactNode + /** The details. */ + children: React.ReactNode + defaultOpen?: boolean + className?: string +}) { + return ( + } + className={cn("border-b border-border", className)} + > +
+ +
{summary}
+ +
+ {actions &&
{actions}
} +
+ +
{children}
+
+
+ ) +} + +export { ExpandRow } diff --git a/registry/winlab/blocks/focus/focus.tsx b/registry/winlab/blocks/focus/focus.tsx new file mode 100644 index 0000000..d622b18 --- /dev/null +++ b/registry/winlab/blocks/focus/focus.tsx @@ -0,0 +1,68 @@ +import * as React from "react" + +import { cn } from "@/registry/winlab/lib/utils" + +// The one loud thing on a page: what the member came to see first (the next +// meeting, today's order, what waits on them). It rises in on the spring and +// is the only place a page uses the primary color for text. Everything +// below it stays quiet. One per page, right under the page header. +function Focus({ className, ...props }: React.ComponentProps<"section">) { + return ( +
+ ) +} + +// What the focus is about, in a word or two: "下一場", "今天". +function FocusLabel({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ) +} + +// The loud line itself. Wrap the part that matters most in FocusHighlight. +function FocusTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +

+ ) +} + +function FocusHighlight({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ) +} + +// A row of facts under the title: who, where, when, an avatar stack. +function FocusMeta({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Focus, FocusHighlight, FocusLabel, FocusMeta, FocusTitle } diff --git a/registry/winlab/ui/number-ticker.tsx b/registry/winlab/ui/number-ticker.tsx new file mode 100644 index 0000000..30da74e --- /dev/null +++ b/registry/winlab/ui/number-ticker.tsx @@ -0,0 +1,44 @@ +import { cn } from "@/registry/winlab/lib/utils" + +const digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + +// A number whose digits roll to their new value on the spring. Each digit is +// a column of 0 to 9 clipped to one line; data-digit slides it. Screen +// readers get the plain number. +function NumberTicker({ + value, + className, +}: { + /** A whole number, 0 or more. */ + value: number + className?: string +}) { + const text = String(Math.max(0, Math.round(value))) + return ( + + {text} + {text.split("").map((digit, index) => ( + + + {digits.map((row) => ( + + {row} + + ))} + + + ))} + + ) +} + +export { NumberTicker }