@vibeuncle/gpgb-ui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { Modal } from './Modal'
4
+ import { Button } from './Button'
5
+ import { t } from '../copy'
6
+ import type { Lang } from '../copy'
7
+
8
+ /** Confirm before a destructive or irreversible action. Name the action in `confirmLabel` ("删除海报", not "确定"). */
9
+ export function ConfirmDialog({ open, onCancel, onConfirm, title, children, confirmLabel, cancelLabel, danger, busy, lang }: {
10
+ open: boolean; onCancel: () => void; onConfirm: () => void; title: string; children?: ReactNode
11
+ confirmLabel?: string; cancelLabel?: string; danger?: boolean; busy?: boolean; lang?: Lang
12
+ }) {
13
+ return (
14
+ <Modal open={open} onClose={onCancel} title={title} lang={lang}
15
+ footer={<>
16
+ <Button variant="outline" onClick={onCancel} disabled={busy}>{cancelLabel ?? t(lang, 'cancel')}</Button>
17
+ <Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} aria-busy={busy || undefined}>{confirmLabel ?? t(lang, 'confirm')}</Button>
18
+ </>}>
19
+ {children}
20
+ </Modal>
21
+ )
22
+ }
@@ -0,0 +1,45 @@
1
+ import { useId } from 'react'
2
+ import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
3
+ import { cn } from '../cn'
4
+
5
+ interface FieldProps {
6
+ label: string
7
+ hint?: string
8
+ error?: string
9
+ children: (props: { id: string; 'aria-invalid'?: true; 'aria-describedby'?: string }) => ReactNode
10
+ }
11
+
12
+ /** Label + control + hint/error, wired for a11y. */
13
+ export function Field({ label, hint, error, children }: FieldProps) {
14
+ const id = useId()
15
+ const descId = hint || error ? `${id}-desc` : undefined
16
+ return (
17
+ <div>
18
+ <label className="ds-label" htmlFor={id}>{label}</label>
19
+ {children({ id, 'aria-invalid': error ? true : undefined, 'aria-describedby': descId })}
20
+ {error ? <p id={descId} className="ds-error" role="alert">{error}</p>
21
+ : hint ? <p id={descId} className="ds-hint mt-1.5">{hint}</p> : null}
22
+ </div>
23
+ )
24
+ }
25
+
26
+ export function Input({ className, small, ...rest }: InputHTMLAttributes<HTMLInputElement> & { small?: boolean }) {
27
+ return <input className={cn('ds-input', small && 'ds-input-sm', className)} {...rest} />
28
+ }
29
+
30
+ export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
31
+ return <textarea className={cn('ds-input', className)} {...rest} />
32
+ }
33
+
34
+ export function Select({ className, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
35
+ return <select className={cn('ds-input', className)} {...rest} />
36
+ }
37
+
38
+ export function Switch({ checked, onChange, label, disabled }: {
39
+ checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean
40
+ }) {
41
+ return (
42
+ <button type="button" role="switch" aria-checked={checked} aria-label={label} disabled={disabled}
43
+ className="ds-switch" onClick={() => onChange(!checked)} />
44
+ )
45
+ }
@@ -0,0 +1,62 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import { cn } from '../cn'
5
+ import { t } from '../copy'
6
+ import type { Lang } from '../copy'
7
+ import { useLink } from '../link'
8
+
9
+ /** Standard page: header, main, footer. Content width via `ds-container`. */
10
+ export function PageShell({ header, children, footer }: { header?: ReactNode; children: ReactNode; footer?: ReactNode }) {
11
+ return (
12
+ <div className="ds-page">
13
+ {header}
14
+ <main id="main" className="ds-container py-10">{children}</main>
15
+ {footer}
16
+ </div>
17
+ )
18
+ }
19
+
20
+ /** Single centred card on paper: sign-in, invite, confirmation, not-found. */
21
+ export function CenteredPage({ children }: { children: ReactNode }) {
22
+ return <div className="ds-page-centered"><main id="main" className="ds-card">{children}</main></div>
23
+ }
24
+
25
+ /** Page title row: serif title + description on the left, actions on the right (primary action first on phones). */
26
+ export function PageTitle({ title, description, actions, breadcrumbs }: { title: string; description?: string; actions?: ReactNode; breadcrumbs?: ReactNode }) {
27
+ return (
28
+ <div className="mb-9">
29
+ {breadcrumbs && <div className="mb-3">{breadcrumbs}</div>}
30
+ <div className="ds-page-title !mb-0">
31
+ <div className="min-w-0">
32
+ <h1 className="font-display text-4xl font-medium sm:text-5xl">{title}</h1>
33
+ {description && <p className="mt-3 max-w-[44em] text-[13px] leading-[1.85] text-ink-soft">{description}</p>}
34
+ </div>
35
+ {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
36
+ </div>
37
+ </div>
38
+ )
39
+ }
40
+
41
+ /** Sidebar + content (editor, settings, admin). Sidebar is sticky from `lg`; on phones put its content in a Sheet instead. */
42
+ export function SidebarLayout({ aside, children, wide }: { aside: ReactNode; children: ReactNode; wide?: boolean }) {
43
+ return <div className={cn('ds-shell', wide && 'ds-shell-wide')}><aside>{aside}</aside><div className="min-w-0">{children}</div></div>
44
+ }
45
+
46
+ export function SiteFooter({ logoSrc, logoAlt = '大道大商', links = [], note, lang }: {
47
+ logoSrc?: string; logoAlt?: string; links?: Array<{ label: string; href: string }>; note?: string; lang?: Lang
48
+ }) {
49
+ const Link = useLink()
50
+ return (
51
+ <footer className="ds-footer">
52
+ <div className="ds-container flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
53
+ <div className="flex items-center gap-3">
54
+ {/* eslint-disable-next-line @next/next/no-img-element */}
55
+ {logoSrc && <img src={logoSrc} alt={logoAlt} className="brand-logo h-5 w-auto opacity-80" />}
56
+ <span>{note ?? `© ${new Date().getFullYear()} ${t(lang, 'copyright')}`}</span>
57
+ </div>
58
+ {links.length > 0 && <nav aria-label="Footer" className="flex flex-wrap gap-x-5 gap-y-1">{links.map(l => <Link key={l.href} href={l.href}>{l.label}</Link>)}</nav>}
59
+ </div>
60
+ </footer>
61
+ )
62
+ }
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+ import { useId, useRef } from 'react'
3
+ import type { ReactNode } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { X } from 'lucide-react'
6
+ import { t } from '../copy'
7
+ import type { Lang } from '../copy'
8
+ import { useFocusTrap, useScrollLock } from '../hooks'
9
+
10
+ /** Dialog: focus moves in and loops, Esc + backdrop close, focus returns to the opener, scroll locked, labelled.
11
+ * Footer buttons: outline "cancel" then the primary action. For a task that needs neither interruption nor protected focus, use a page, not a modal. */
12
+ export function Modal({ open, onClose, title, children, footer, lang, closeLabel }: {
13
+ open: boolean; onClose: () => void; title: string; children: ReactNode; footer?: ReactNode; lang?: Lang; closeLabel?: string
14
+ }) {
15
+ const titleId = useId()
16
+ const ref = useRef<HTMLDivElement>(null)
17
+ useFocusTrap(open, ref, onClose)
18
+ useScrollLock(open)
19
+ if (!open) return null
20
+ return createPortal(
21
+ <div className="ds-backdrop" onClick={onClose}>
22
+ <div ref={ref} className="ds-modal" role="dialog" aria-modal="true" aria-labelledby={titleId} onClick={e => e.stopPropagation()}>
23
+ <div className="mb-4 flex items-start justify-between gap-4">
24
+ <h2 id={titleId} className="font-display text-xl text-ink">{title}</h2>
25
+ <button type="button" className="ds-btn ds-btn-ghost ds-btn-icon" onClick={onClose} aria-label={closeLabel ?? t(lang, 'close')}>
26
+ <X size={18} />
27
+ </button>
28
+ </div>
29
+ <div className="text-sm text-ink-soft">{children}</div>
30
+ {footer && <div className="mt-6 flex justify-end gap-2">{footer}</div>}
31
+ </div>
32
+ </div>,
33
+ document.body,
34
+ )
35
+ }
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+ import { ChevronRight, Menu as MenuIcon, Check } from 'lucide-react'
3
+ import type { ReactNode } from 'react'
4
+ import { cn } from '../cn'
5
+ import { t } from '../copy'
6
+ import type { Lang } from '../copy'
7
+ import { Sheet } from './Sheet'
8
+ import { useLink } from '../link'
9
+
10
+ export interface NavItem { label: string; href: string; icon?: ReactNode; current?: boolean }
11
+
12
+ /** Header navigation: pill links, current page = accent-soft wash with aria-current="page". Hide on phones; use NavDrawer there. */
13
+ export function NavLinks({ items, lang, className }: { items: NavItem[]; lang?: Lang; className?: string }) {
14
+ const Link = useLink()
15
+ return (
16
+ <nav aria-label={t(lang, 'mainNav')} className={cn('flex items-center gap-1', className)}>
17
+ {items.map(i => (
18
+ <Link key={i.href} href={i.href} className="ds-nav-link" aria-current={i.current ? 'page' : undefined}>{i.icon}{i.label}</Link>
19
+ ))}
20
+ </nav>
21
+ )
22
+ }
23
+
24
+ /** Phone navigation: hamburger button + left drawer. Place the button in the header, visible below `md`. */
25
+ export function NavDrawer({ items, open, onOpenChange, title, footer, lang }: {
26
+ items: NavItem[]; open: boolean; onOpenChange: (v: boolean) => void; title?: string; footer?: ReactNode; lang?: Lang
27
+ }) {
28
+ const Link = useLink()
29
+ return (
30
+ <Sheet open={open} onClose={() => onOpenChange(false)} side="left" title={title ?? t(lang, 'menu')} lang={lang}>
31
+ <nav aria-label={t(lang, 'mainNav')} className="flex flex-col gap-1">
32
+ {items.map(i => (
33
+ <Link key={i.href} href={i.href} className="ds-drawer-link" aria-current={i.current ? 'page' : undefined} onClick={() => onOpenChange(false)}>{i.icon}{i.label}</Link>
34
+ ))}
35
+ </nav>
36
+ {footer && <div className="mt-6 border-t border-border-soft pt-4">{footer}</div>}
37
+ </Sheet>
38
+ )
39
+ }
40
+
41
+ export function MenuButton({ onClick, expanded, lang }: { onClick: () => void; expanded?: boolean; lang?: Lang }) {
42
+ return (
43
+ <button type="button" className="ds-btn ds-btn-ghost ds-btn-icon" onClick={onClick} aria-label={t(lang, 'openMenu')} aria-expanded={expanded} aria-haspopup="dialog">
44
+ <MenuIcon size={20} />
45
+ </button>
46
+ )
47
+ }
48
+
49
+ /** Where you are. The last item is the current page and is not a link. */
50
+ export function Breadcrumbs({ items, lang }: { items: Array<{ label: string; href?: string }>; lang?: Lang }) {
51
+ const Link = useLink()
52
+ return (
53
+ <nav aria-label={t(lang, 'breadcrumb')}>
54
+ <ol className="ds-crumbs">
55
+ {items.map((c, i) => {
56
+ const last = i === items.length - 1
57
+ return (
58
+ <li key={i}>
59
+ {last || !c.href ? <span aria-current={last ? 'page' : undefined}>{c.label}</span> : <Link href={c.href} className="ds-link">{c.label}</Link>}
60
+ {!last && <ChevronRight size={14} aria-hidden className="text-ink-faint" />}
61
+ </li>
62
+ )
63
+ })}
64
+ </ol>
65
+ </nav>
66
+ )
67
+ }
68
+
69
+ /** Multi-step flow progress. On phones only the current step shows its label. `current` is zero-based. */
70
+ export function StepIndicator({ steps, current, lang }: { steps: string[]; current: number; lang?: Lang }) {
71
+ return (
72
+ <ol className="ds-steps" aria-label={t(lang, 'steps')}>
73
+ {steps.map((label, i) => {
74
+ const state = i < current ? 'done' : i === current ? 'current' : 'todo'
75
+ return (
76
+ <li key={i} className="flex items-center gap-1">
77
+ {i > 0 && <span className="ds-step-line" aria-hidden />}
78
+ <span className="ds-step" data-state={state} aria-current={state === 'current' ? 'step' : undefined}>
79
+ <span className="ds-step-dot">{state === 'done' ? <Check size={12} strokeWidth={3} aria-hidden /> : i + 1}</span>
80
+ <span className="ds-step-label">{label}</span>
81
+ </span>
82
+ </li>
83
+ )
84
+ })}
85
+ </ol>
86
+ )
87
+ }
88
+
89
+ /** 中 / EN switch. Controlled; the app owns persistence (cookie, route, or state). */
90
+ export function LangToggle({ value, onChange, lang }: { value: Lang; onChange: (v: Lang) => void; lang?: Lang }) {
91
+ return (
92
+ <div className="ds-tabs" role="group" aria-label={t(lang ?? value, 'language')}>
93
+ {(['zh', 'en'] as const).map(l => (
94
+ <button key={l} type="button" className="ds-tab" aria-pressed={value === l} lang={l} onClick={() => onChange(l)}>{l === 'zh' ? '中' : 'EN'}</button>
95
+ ))}
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+ import { cloneElement, useCallback, useEffect, useId, useRef, useState } from 'react'
3
+ import type { KeyboardEvent, ReactElement, ReactNode } from 'react'
4
+ import { Check } from 'lucide-react'
5
+ import { cn } from '../cn'
6
+ import { useDismiss } from '../hooks'
7
+ import { useLink } from '../link'
8
+
9
+ type Align = 'start' | 'end'
10
+
11
+ /** Anchored panel for small forms or extra detail. Not for menus (use Menu) or hints (use Tooltip). `trigger` must be one button element. */
12
+ export function Popover({ trigger, children, align = 'start', label }: {
13
+ trigger: ReactElement<Record<string, unknown>>; children: ReactNode; align?: Align; label: string
14
+ }) {
15
+ const [open, setOpen] = useState(false)
16
+ const ref = useRef<HTMLDivElement>(null)
17
+ const id = useId()
18
+ const close = useCallback(() => setOpen(false), [])
19
+ useDismiss(open, ref, close)
20
+ return (
21
+ <div className="ds-popover-wrap" ref={ref}>
22
+ {cloneElement(trigger, { 'aria-haspopup': 'dialog', 'aria-expanded': open, 'aria-controls': open ? id : undefined, onClick: () => setOpen(o => !o) })}
23
+ {open && <div id={id} role="dialog" aria-label={label} className={cn('ds-popover ds-popover-body', align === 'end' ? 'ds-popover-end' : 'ds-popover-start')}>{children}</div>}
24
+ </div>
25
+ )
26
+ }
27
+
28
+ export type MenuEntry =
29
+ | { type?: 'item'; label: string; onSelect?: () => void; href?: string; icon?: ReactNode; danger?: boolean; disabled?: boolean; checked?: boolean; hint?: string }
30
+ | { type: 'separator' }
31
+ | { type: 'label'; label: string }
32
+
33
+ /** Dropdown menu. Arrow keys / Home / End move, Esc closes and returns focus to the trigger, Enter or Space selects.
34
+ * `trigger` must be one button element. Items with `href` render as links. Opens below; align="end" for right-edge triggers. */
35
+ export function Menu({ trigger, items, align = 'start', label }: {
36
+ trigger: ReactElement<Record<string, unknown>>; items: MenuEntry[]; align?: Align; label: string
37
+ }) {
38
+ const Link = useLink()
39
+ const [open, setOpen] = useState(false)
40
+ const wrap = useRef<HTMLDivElement>(null)
41
+ const list = useRef<HTMLDivElement>(null)
42
+ const id = useId()
43
+ const close = useCallback((refocus = false) => {
44
+ setOpen(false)
45
+ if (refocus) wrap.current?.querySelector<HTMLElement>('[aria-haspopup]')?.focus()
46
+ }, [])
47
+ useDismiss(open, wrap, () => setOpen(false))
48
+
49
+ const enabled = () => Array.from(list.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([aria-disabled="true"])') ?? [])
50
+ const focusAt = (i: number) => { const e = enabled(); if (e.length) e[(i + e.length) % e.length].focus() }
51
+ // Focus moves in an effect right after the menu commits (not rAF), so it is deterministic.
52
+ const pendingFocus = useRef<'first' | 'last' | null>(null)
53
+ useEffect(() => {
54
+ if (open && pendingFocus.current) { focusAt(pendingFocus.current === 'last' ? -1 : 0); pendingFocus.current = null }
55
+ }, [open])
56
+ const openAndFocus = (last = false) => {
57
+ pendingFocus.current = last ? 'last' : 'first'
58
+ if (open) { focusAt(last ? -1 : 0); pendingFocus.current = null } else setOpen(true)
59
+ }
60
+
61
+ const onTriggerKey = (e: KeyboardEvent) => {
62
+ if (e.key === 'ArrowDown') { e.preventDefault(); openAndFocus() }
63
+ if (e.key === 'ArrowUp') { e.preventDefault(); openAndFocus(true) }
64
+ }
65
+ const onListKey = (e: KeyboardEvent) => {
66
+ const e2 = enabled(); const i = e2.indexOf(document.activeElement as HTMLElement)
67
+ if (e.key === 'ArrowDown') { e.preventDefault(); focusAt(i + 1) }
68
+ else if (e.key === 'ArrowUp') { e.preventDefault(); focusAt(i - 1) }
69
+ else if (e.key === 'Home') { e.preventDefault(); focusAt(0) }
70
+ else if (e.key === 'End') { e.preventDefault(); focusAt(-1) }
71
+ else if (e.key === 'Escape') { e.preventDefault(); close(true) }
72
+ else if (e.key === 'Tab') close()
73
+ }
74
+
75
+ return (
76
+ <div className="ds-popover-wrap" ref={wrap}>
77
+ {cloneElement(trigger, { 'aria-haspopup': 'menu', 'aria-expanded': open, 'aria-controls': open ? id : undefined, onClick: () => (open ? close() : openAndFocus()), onKeyDown: onTriggerKey })}
78
+ {open && (
79
+ <div id={id} ref={list} role="menu" aria-label={label} onKeyDown={onListKey}
80
+ className={cn('ds-popover', align === 'end' ? 'ds-popover-end' : 'ds-popover-start')}>
81
+ {items.map((it, i) => {
82
+ if (it.type === 'separator') return <div key={i} role="separator" className="ds-menu-sep" />
83
+ if (it.type === 'label') return <div key={i} className="ds-menu-label" aria-hidden>{it.label}</div>
84
+ const cls = cn('ds-menu-item', it.danger && 'ds-menu-item-danger')
85
+ const inner = <>
86
+ {it.icon}<span>{it.label}</span>
87
+ {it.checked ? <Check size={16} className="ml-auto" aria-hidden /> : it.hint ? <span className="ds-menu-hint">{it.hint}</span> : null}
88
+ </>
89
+ const role = it.checked !== undefined ? 'menuitemradio' : 'menuitem'
90
+ return it.href
91
+ ? <Link key={i} role={role} aria-checked={it.checked} aria-disabled={it.disabled || undefined} href={it.href} className={cls} tabIndex={-1} onClick={() => close()}>{inner}</Link>
92
+ : <button key={i} type="button" role={role} aria-checked={it.checked} aria-disabled={it.disabled || undefined} className={cls} tabIndex={-1}
93
+ onClick={() => { if (it.disabled) return; it.onSelect?.(); close(true) }}>{inner}</button>
94
+ })}
95
+ </div>
96
+ )}
97
+ </div>
98
+ )
99
+ }
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+ import { useRef } from 'react'
3
+ import type { ReactNode } from 'react'
4
+ import { X } from 'lucide-react'
5
+ import { t } from '../copy'
6
+ import type { Lang } from '../copy'
7
+ import { useFocusTrap, useScrollLock } from '../hooks'
8
+
9
+ /** Bottom sheet (phone: pickers, secondary panels) or left drawer (mobile nav). Stays mounted so it can slide; closed = inert.
10
+ * Children render only after the first open, so heavy content doesn't load until needed. */
11
+ export function Sheet({ open, onClose, title, side = 'bottom', children, lang }: {
12
+ open: boolean; onClose: () => void; title: string; side?: 'bottom' | 'left'; children: ReactNode; lang?: Lang
13
+ }) {
14
+ const ref = useRef<HTMLDivElement>(null)
15
+ const opened = useRef(open)
16
+ if (open) opened.current = true
17
+ useFocusTrap(open, ref, onClose)
18
+ useScrollLock(open)
19
+ return (
20
+ <div className="ds-sheet-root" data-open={open} inert={!open} aria-hidden={!open}>
21
+ <div className="ds-sheet-scrim" onClick={onClose} />
22
+ <div ref={ref} className={`ds-sheet ds-sheet-${side}`} role="dialog" aria-modal="true" aria-label={title}>
23
+ {side === 'bottom' && <div className="ds-sheet-grab" aria-hidden />}
24
+ <div className="ds-sheet-head">
25
+ <h2 className="font-display text-base text-ink">{title}</h2>
26
+ <button type="button" className="ds-btn ds-btn-ghost ds-btn-icon" onClick={onClose} aria-label={t(lang, 'close')}><X size={18} /></button>
27
+ </div>
28
+ <div className="ds-sheet-body">{opened.current ? children : null}</div>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { useLink } from '../link'
4
+
5
+ /** Brand header: graphic logo, a hairline divider, then the app title, optional desktop nav, actions right. Copy `assets/logo/daoshang-horizontal-mark-dark.png`
6
+ * into the app's public/logo/ and pass '/logo/daoshang-horizontal-mark-dark.png'. The dark-slate mark flips to light via .brand-logo.
7
+ * Phones: pass `menuButton` (see MenuButton + NavDrawer) and hide `nav` below `md` (NavLinks className="hidden md:flex"). */
8
+ export function SiteHeader({ logoSrc, logoAlt = '大道大商', title, nav, actions, menuButton, href = '/', containerClassName = 'ds-container', homeLabel }: {
9
+ logoSrc: string; logoAlt?: string; title?: string; nav?: ReactNode; actions?: ReactNode; menuButton?: ReactNode; href?: string
10
+ /** Width/gutter of the header content. Default `ds-container` (72rem). Narrow apps pass e.g. 'mx-auto max-w-2xl px-5' to line up with their page column. */
11
+ containerClassName?: string
12
+ /** aria-label for the logo link when there is no title (e.g. "接龙 首页"). */
13
+ homeLabel?: string
14
+ }) {
15
+ const Link = useLink()
16
+ return (
17
+ <header className="ds-header">
18
+ <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 ds-btn ds-btn-primary">Skip to content</a>
19
+ <div className={`${containerClassName} flex items-center justify-between gap-x-4 gap-y-2 py-4`}>
20
+ <div className="flex min-w-0 items-center gap-3">
21
+ {menuButton && <div className="md:hidden">{menuButton}</div>}
22
+ <Link href={href} aria-label={homeLabel} className="flex min-w-0 items-center gap-4">
23
+ {/* eslint-disable-next-line @next/next/no-img-element */}
24
+ <img src={logoSrc} alt={logoAlt} className="brand-logo h-6 w-auto shrink-0" />
25
+ {title && <><span className="ds-brand-divider" aria-hidden /><span className="font-display truncate text-lg text-ink">{title}</span></>}
26
+ </Link>
27
+ </div>
28
+ {nav && <div className="hidden md:block">{nav}</div>}
29
+ {actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
30
+ </div>
31
+ </header>
32
+ )
33
+ }
@@ -0,0 +1,12 @@
1
+ import { Check } from 'lucide-react'
2
+ import { cn } from '../cn'
3
+
4
+ /** The brand's seal: an orange stamp that lands once on a meaningful success (published, saved, signed up).
5
+ * Mount it when the success happens; remount (change `key`) to replay. Use at most one per view. */
6
+ export function Stamp({ large, label = '完成', className }: { large?: boolean; label?: string; className?: string }) {
7
+ return (
8
+ <span className={cn('ds-stamp', large && 'ds-stamp-lg', className)} role="img" aria-label={label}>
9
+ <Check size={large ? 40 : 28} strokeWidth={2.5} aria-hidden />
10
+ </span>
11
+ )
12
+ }
@@ -0,0 +1,35 @@
1
+ import type { HTMLAttributes } from 'react'
2
+ import { cn } from '../cn'
3
+
4
+ export function Card({ interactive, className, ...rest }: HTMLAttributes<HTMLDivElement> & { interactive?: boolean }) {
5
+ return <div className={cn('ds-card', interactive && 'ds-card-interactive', className)} {...rest} />
6
+ }
7
+
8
+ export type BadgeTone = 'accent' | 'ink' | 'tint' | 'outline' | 'danger'
9
+ export function Badge({ tone = 'tint', small, className, ...rest }:
10
+ HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone; small?: boolean }) {
11
+ return <span className={cn('ds-badge', `ds-badge-${tone}`, small && 'ds-badge-sm', className)} {...rest} />
12
+ }
13
+
14
+ export function EmptyState({ title, hint, action }: { title: string; hint?: string; action?: React.ReactNode }) {
15
+ return (
16
+ <div className="ds-empty">
17
+ <p className="font-display text-lg text-ink">{title}</p>
18
+ {hint && <p className="ds-hint mt-2">{hint}</p>}
19
+ {action && <div className="mt-5">{action}</div>}
20
+ </div>
21
+ )
22
+ }
23
+
24
+ export function Skeleton({ className }: { className?: string }) {
25
+ return <div className={cn('ds-skeleton', className)} aria-hidden />
26
+ }
27
+
28
+ export function Spinner({ label = '加载中' }: { label?: string }) {
29
+ return <div className="ds-spinner" role="status" aria-label={label} />
30
+ }
31
+
32
+ export function Alert({ tone, className, ...rest }: HTMLAttributes<HTMLDivElement> & { tone?: 'danger' | 'warn' }) {
33
+ return <div role={tone === 'danger' ? 'alert' : 'status'}
34
+ className={cn('ds-alert', tone && `ds-alert-${tone}`, className)} {...rest} />
35
+ }
@@ -0,0 +1,20 @@
1
+ import { cn } from '../cn'
2
+
3
+ export function Tabs<T extends string>({ items, value, onChange, label, className }: {
4
+ items: ReadonlyArray<{ value: T; label: string }>
5
+ value: T
6
+ onChange: (v: T) => void
7
+ label: string
8
+ className?: string
9
+ }) {
10
+ return (
11
+ <div className={cn('ds-tabs', className)} role="tablist" aria-label={label}>
12
+ {items.map(it => (
13
+ <button key={it.value} type="button" role="tab" className="ds-tab"
14
+ aria-selected={it.value === value} onClick={() => onChange(it.value)}>
15
+ {it.label}
16
+ </button>
17
+ ))}
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+ import { useEffect } from 'react'
3
+ import { cn } from '../cn'
4
+
5
+ /** Controlled toast: render when `message` is set; it calls onDone after `ms`. */
6
+ export function Toast({ message, tone, onDone, ms = 3000 }: {
7
+ message: string | null; tone?: 'error'; onDone: () => void; ms?: number
8
+ }) {
9
+ useEffect(() => {
10
+ if (!message) return
11
+ const t = setTimeout(onDone, ms)
12
+ return () => clearTimeout(t)
13
+ }, [message, ms, onDone])
14
+ if (!message) return null
15
+ return <div role={tone === 'error' ? 'alert' : 'status'} className={cn('ds-toast', tone === 'error' && 'ds-toast-error')}>{message}</div>
16
+ }
@@ -0,0 +1,15 @@
1
+ import { cloneElement, useId } from 'react'
2
+ import type { ReactElement } from 'react'
3
+ import { cn } from '../cn'
4
+
5
+ /** Short, non-essential hint (a name for an icon button, a keyboard shortcut). Shows on hover and keyboard focus after 350ms; hidden on touch.
6
+ * Never put required information here. Child must be one focusable element. */
7
+ export function Tooltip({ label, children, placement = 'top' }: { label: string; children: ReactElement<Record<string, unknown>>; placement?: 'top' | 'bottom' }) {
8
+ const id = useId()
9
+ return (
10
+ <span className={cn('ds-tip-wrap', placement === 'bottom' && 'ds-tip-bottom')}>
11
+ {cloneElement(children, { 'aria-describedby': id })}
12
+ <span id={id} role="tooltip" className="ds-tip">{label}</span>
13
+ </span>
14
+ )
15
+ }
package/src/copy.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** Standard UI strings. Chinese is primary; components default to 'zh' and accept `lang`. See "Content & voice" in DESIGN_SYSTEM.md. */
2
+ export type Lang = 'zh' | 'en'
3
+
4
+ export const labels = {
5
+ zh: {
6
+ cancel: '取消', confirm: '确认', save: '保存', delete: '删除', close: '关闭', retry: '重试', back: '返回', next: '下一步',
7
+ done: '完成', loading: '加载中', search: '搜索', menu: '菜单', openMenu: '打开菜单', mainNav: '主导航', breadcrumb: '当前位置',
8
+ steps: '步骤', language: '语言', signIn: '登录', signOut: '退出登录', settings: '设置', help: '帮助', workspace: '工作区',
9
+ switchWorkspace: '切换工作区', account: '账户', viewer: '仅查看', copyright: '版权所有',
10
+ },
11
+ en: {
12
+ cancel: 'Cancel', confirm: 'Confirm', save: 'Save', delete: 'Delete', close: 'Close', retry: 'Try again', back: 'Back', next: 'Next',
13
+ done: 'Done', loading: 'Loading', search: 'Search', menu: 'Menu', openMenu: 'Open menu', mainNav: 'Main navigation', breadcrumb: 'Breadcrumb',
14
+ steps: 'Steps', language: 'Language', signIn: 'Sign in', signOut: 'Sign out', settings: 'Settings', help: 'Help', workspace: 'Workspace',
15
+ switchWorkspace: 'Switch workspace', account: 'Account', viewer: 'View only', copyright: 'All rights reserved',
16
+ },
17
+ } as const
18
+
19
+ export type LabelKey = keyof typeof labels.zh
20
+ export const t = (lang: Lang | undefined, key: LabelKey) => labels[lang ?? 'zh'][key]
package/src/hooks.ts ADDED
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+ import { useEffect } from 'react'
3
+ import type { RefObject } from 'react'
4
+
5
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
6
+
7
+ /** While `open`: moves focus into `ref`, loops Tab inside it, calls onEscape on Esc, and restores focus on close. */
8
+ export function useFocusTrap(open: boolean, ref: RefObject<HTMLElement | null>, onEscape: () => void) {
9
+ useEffect(() => {
10
+ if (!open) return
11
+ const prev = document.activeElement as HTMLElement | null
12
+ const el = ref.current
13
+ const items = () => (el ? Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE)) : [])
14
+ ;(items()[0] ?? el)?.focus()
15
+ const onKey = (e: KeyboardEvent) => {
16
+ if (e.key === 'Escape') { e.stopPropagation(); onEscape(); return }
17
+ if (e.key !== 'Tab') return
18
+ const list = items()
19
+ if (!list.length) { e.preventDefault(); return }
20
+ const first = list[0], last = list[list.length - 1]
21
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
22
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
23
+ }
24
+ document.addEventListener('keydown', onKey)
25
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
26
+ }, [open, ref, onEscape])
27
+ }
28
+
29
+ /** Closes on outside pointer-down or Esc while `open`. */
30
+ export function useDismiss(open: boolean, ref: RefObject<HTMLElement | null>, onDismiss: () => void) {
31
+ useEffect(() => {
32
+ if (!open) return
33
+ const down = (e: MouseEvent | TouchEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onDismiss() }
34
+ const key = (e: KeyboardEvent) => { if (e.key === 'Escape') onDismiss() }
35
+ document.addEventListener('mousedown', down)
36
+ document.addEventListener('touchstart', down)
37
+ document.addEventListener('keydown', key)
38
+ return () => { document.removeEventListener('mousedown', down); document.removeEventListener('touchstart', down); document.removeEventListener('keydown', key) }
39
+ }, [open, ref, onDismiss])
40
+ }
41
+
42
+ /** Locks page scroll while `locked`. */
43
+ export function useScrollLock(locked: boolean) {
44
+ useEffect(() => {
45
+ if (!locked) return
46
+ const prev = document.body.style.overflow
47
+ document.body.style.overflow = 'hidden'
48
+ return () => { document.body.style.overflow = prev }
49
+ }, [locked])
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export * from './cn'
2
+ export * from './components/Button'
3
+ export * from './components/Field'
4
+ export * from './components/Surface'
5
+ export * from './components/Tabs'
6
+ export * from './components/Modal'
7
+ export * from './components/Toast'
8
+ export * from './components/SiteHeader'
9
+ export * from './components/Popover'
10
+ export * from './components/Tooltip'
11
+ export * from './components/ConfirmDialog'
12
+ export * from './components/Sheet'
13
+ export * from './components/Nav'
14
+ export * from './components/Account'
15
+ export * from './components/Layout'
16
+ export * from './copy'
17
+ export * from './hooks'
18
+ export * from './components/Stamp'
19
+ export * from './link'
package/src/link.tsx ADDED
@@ -0,0 +1,12 @@
1
+ 'use client'
2
+ import { createContext, useContext } from 'react'
3
+ import type { ElementType } from 'react'
4
+
5
+ const LinkContext = createContext<ElementType>('a')
6
+
7
+ /** Tell the design-system components which link component to render, once, near the root:
8
+ * `<LinkProvider value={Link}>` with `import Link from 'next/link'`. Default is a plain `<a>` (full page loads). */
9
+ export const LinkProvider = LinkContext.Provider
10
+
11
+ /** The link component for the current app. Use for any internal navigation inside design-system components. */
12
+ export const useLink = () => useContext(LinkContext)