feat(blocks): add reusable application shell blocks

- add appearance state, theme presets, preference controls, and preview components

- add responsive primary, nested, mobile, and flyout navigation with route-aware breadcrumbs

- add application layout, sidebar state, header actions, user menu, query refresh, and scroll utilities

- add configurable chat and notification surfaces backed by consumer-provided data and queries

- expose package entry points and cover theme, state, and command behavior with tests
This commit is contained in:
Maofeng
2026-07-29 21:22:47 +08:00
parent 7b9270ef67
commit 011e7d8115
51 changed files with 5253 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@workspace/blocks",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"format": "prettier --write \"**/*.{ts,tsx}\"",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@base-ui/utils": "^0.3.1",
"@floating-ui/utils": "^0.2.12",
"@hugeicons/core-free-icons": "^4.2.3",
"@hugeicons/react": "^1.1.9",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@workspace/ui": "workspace:*",
"lucide-react": "^1.27.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./appearance": "./src/blocks/appearance/index.ts",
"./chats": "./src/blocks/chats/index.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts",
"./lib/*": "./src/lib/*.tsx",
"./layout": "./src/blocks/layout/index.ts",
"./navigation": "./src/blocks/navigation/index.ts",
"./notifications": "./src/blocks/notifications/index.ts"
}
}
@@ -0,0 +1,88 @@
import { useCallback, useState } from "react"
import { useHotkey } from "@tanstack/react-hotkeys"
import { useResolvedTheme, useUiState } from "./ui-state"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog"
import {
NavigationActionTarget,
navigationActions,
} from "../../lib/command-actions"
import { appearanceDialogHandle } from "./dialog"
import { CompactPreference, ModePreference } from "./preferences"
import { ThemeConfigPanel } from "./theme-config-panel"
export function AppearanceController() {
const [isOpen, setIsOpen] = useState(false)
const [, setThemeMode] = useUiState("theme-mode")
const resolvedTheme = useResolvedTheme()
const toggleDialog = useCallback(() => {
if (isOpen) {
appearanceDialogHandle.close()
} else {
appearanceDialogHandle.open(null)
}
}, [isOpen])
const toggleTheme = useCallback(() => {
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setThemeMode])
useHotkey("Mod+,", toggleDialog, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: "界面外观",
description: "打开主题与布局偏好",
},
})
useHotkey("Mod+D", toggleTheme, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: "切换主题",
description: "在浅色与深色主题之间切换",
},
})
return (
<Dialog handle={appearanceDialogHandle} onOpenChange={setIsOpen}>
<NavigationActionTarget
action={navigationActions.appearance}
onInvoke={() => appearanceDialogHandle.open(null)}
/>
<DialogContent className="sm:max-w-max">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="-mx-6 max-h-[75vh] space-y-10 overflow-y-auto px-6">
<div className="flex flex-col gap-6 lg:flex-row">
<ModePreference />
<CompactPreference />
</div>
<div className="flex flex-col gap-6 lg:flex-row">
<ThemeConfigPanel
scheme="light"
active={resolvedTheme === "light"}
description="当系统设置为浅色模式时,将使用此主题"
/>
<ThemeConfigPanel
scheme="dark"
active={resolvedTheme === "dark"}
description="当系统设置为深色模式时,将使用此主题"
/>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,17 @@
import type { ComponentProps } from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { DialogTrigger } from "@workspace/ui/components/dialog"
export const appearanceDialogHandle = DialogPrimitive.createHandle()
export function AppearanceTrigger(
props: Omit<ComponentProps<typeof DialogTrigger>, "handle" | "nativeButton">
) {
return (
<DialogTrigger
handle={appearanceDialogHandle}
nativeButton={false}
{...props}
/>
)
}
@@ -0,0 +1,20 @@
export { appearanceDialogHandle, AppearanceTrigger } from "./dialog"
export { AppearanceController } from "./controller"
export {
UiStateProvider,
type UiStateProviderProps,
type UiStateChangeHandler,
useResolvedTheme,
useUiState,
} from "./ui-state"
export {
defaultUiState,
isUiStateUpdate,
type ThemeMode,
type UiState,
uiStateDefinitions,
type UiStateKey,
type UiStateUpdate,
type UiStateValue,
} from "./state"
export * from "./theme"
@@ -0,0 +1,86 @@
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select"
import { Switch } from "@workspace/ui/components/switch"
import type { ThemeMode } from "./state"
import { useUiState } from "./ui-state"
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
{ label: "使用浅色", value: "light" },
{ label: "使用深色", value: "dark" },
{ label: "跟随系统", value: "system" },
]
function ModeIcon({ mode }: { mode: ThemeMode | null }) {
if (mode === "light") return <SunIcon />
if (mode === "dark") return <MoonIcon />
return <MonitorIcon />
}
export function ModePreference() {
const [themeMode, setThemeMode] = useUiState("theme-mode")
const description = {
light: "界面将始终使用浅色主题",
dark: "界面将始终使用深色主题",
system: "界面主题将跟随系统外观设置",
}[themeMode]
return (
<div className="lg:flex-1">
<h2 className="font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select
items={modeItems}
value={themeMode}
onValueChange={(mode) => {
if (mode) setThemeMode(mode)
}}
>
<SelectTrigger className="mt-2 w-40">
<SelectValue>
{(value: ThemeMode | null) => (
<>
<ModeIcon mode={value} />
{modeItems
.find((item) => item.value === value)
?.label.slice(-2)}
</>
)}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{modeItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
<ModeIcon mode={item.value} />
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
export function CompactPreference() {
const [isCompacted, setIsCompacted] = useUiState("main-compacted")
return (
<div className="lg:flex-1">
<h2 className="font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground">
使
</p>
<Switch
className="mt-2"
checked={isCompacted}
onCheckedChange={setIsCompacted}
/>
</div>
)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest"
import { defaultUiState, isUiStateUpdate, uiStateDefinitions } from "./state"
describe("UI state", () => {
it("keeps the main content full width by default", () => {
expect(defaultUiState["main-compacted"]).toBe(false)
})
it("serializes and parses the compacted main state", () => {
const definition = uiStateDefinitions["main-compacted"]
expect(definition.serialize(true)).toBe("1")
expect(definition.serialize(false)).toBe("0")
expect(definition.parse("1")).toBe(true)
expect(definition.parse("0")).toBe(false)
expect(definition.parse(undefined)).toBe(false)
})
it("validates compacted main updates", () => {
expect(isUiStateUpdate({ key: "main-compacted", value: true })).toBe(true)
expect(isUiStateUpdate({ key: "main-compacted", value: "true" })).toBe(
false
)
})
it("keeps and persists the sidebar collapsed state", () => {
const definition = uiStateDefinitions["sidebar-collapsed"]
expect(defaultUiState["sidebar-collapsed"]).toBe(false)
expect(definition.serialize(true)).toBe("1")
expect(definition.parse("1")).toBe(true)
expect(definition.parse("true")).toBe(true)
expect(definition.parse(undefined)).toBe(false)
expect(isUiStateUpdate({ key: "sidebar-collapsed", value: true })).toBe(
true
)
})
it("serializes and parses theme color settings", () => {
const definition = uiStateDefinitions["theme-light"]
const value = {
baseColor: "zinc",
accentColor: "violet",
} as const
expect(definition.parse(definition.serialize(value))).toEqual(value)
expect(definition.parse("invalid json")).toEqual({
baseColor: "neutral",
accentColor: "blue",
})
})
it("validates light and dark theme updates", () => {
expect(
isUiStateUpdate({
key: "theme-dark",
value: { baseColor: "stone", accentColor: "amber" },
})
).toBe(true)
expect(
isUiStateUpdate({
key: "theme-light",
value: { baseColor: "unknown", accentColor: "blue" },
})
).toBe(false)
})
})
@@ -0,0 +1,125 @@
import { accentColors, baseColors, type ThemeColorConfig } from "./theme"
export type ThemeMode = "light" | "dark" | "system"
export type UiState = {
"main-compacted": boolean
"sidebar-collapsed": boolean
"theme-mode": ThemeMode
"theme-light": ThemeColorConfig
"theme-dark": ThemeColorConfig
}
export type UiStateKey = keyof UiState
export type UiStateValue<K extends UiStateKey> = UiState[K]
type UiStateDefinition<T> = {
cookie: string
defaultValue: T
parse: (value: string | undefined) => T
serialize: (value: T) => string
}
const defaultThemeColorConfig: ThemeColorConfig = {
baseColor: "neutral",
accentColor: "blue",
}
function isThemeColorConfig(value: unknown): value is ThemeColorConfig {
if (!value || typeof value !== "object") return false
const config = value as {
baseColor?: unknown
accentColor?: unknown
}
return (
typeof config.baseColor === "string" &&
baseColors.some((color) => color === config.baseColor) &&
typeof config.accentColor === "string" &&
accentColors.some((color) => color === config.accentColor)
)
}
function parseThemeColorConfig(value: string | undefined): ThemeColorConfig {
if (!value) return { ...defaultThemeColorConfig }
try {
const parsed: unknown = JSON.parse(value)
return isThemeColorConfig(parsed) ? parsed : { ...defaultThemeColorConfig }
} catch {
return { ...defaultThemeColorConfig }
}
}
export const uiStateDefinitions: {
[K in UiStateKey]: UiStateDefinition<UiState[K]>
} = {
"main-compacted": {
cookie: "ui-main-compacted",
defaultValue: false,
parse: (value) => value === "1",
serialize: (value) => (value ? "1" : "0"),
},
"sidebar-collapsed": {
cookie: "ui-sidebar-collapsed",
defaultValue: false,
parse: (value) => value === "true" || value === "1",
serialize: (value) => (value ? "1" : "0"),
},
"theme-mode": {
cookie: "ui-theme",
defaultValue: "system",
parse: (value) =>
value === "light" || value === "dark" || value === "system"
? value
: "system",
serialize: (value) => value,
},
"theme-light": {
cookie: "ui-theme-light",
defaultValue: { ...defaultThemeColorConfig },
parse: parseThemeColorConfig,
serialize: JSON.stringify,
},
"theme-dark": {
cookie: "ui-theme-dark",
defaultValue: { ...defaultThemeColorConfig },
parse: parseThemeColorConfig,
serialize: JSON.stringify,
},
}
export const defaultUiState: UiState = {
"main-compacted": uiStateDefinitions["main-compacted"].defaultValue,
"sidebar-collapsed": uiStateDefinitions["sidebar-collapsed"].defaultValue,
"theme-mode": uiStateDefinitions["theme-mode"].defaultValue,
"theme-light": uiStateDefinitions["theme-light"].defaultValue,
"theme-dark": uiStateDefinitions["theme-dark"].defaultValue,
}
export type UiStateUpdate = {
[K in UiStateKey]: { key: K; value: UiState[K] }
}[UiStateKey]
export function isUiStateUpdate(value: unknown): value is UiStateUpdate {
if (!value || typeof value !== "object") return false
const update = value as { key?: unknown; value?: unknown }
switch (update.key) {
case "main-compacted":
case "sidebar-collapsed":
return typeof update.value === "boolean"
case "theme-mode":
return (
update.value === "light" ||
update.value === "dark" ||
update.value === "system"
)
case "theme-light":
case "theme-dark":
return isThemeColorConfig(update.value)
default:
return false
}
}
@@ -0,0 +1,114 @@
import type { CSSProperties } from "react"
import { cn } from "@workspace/ui/lib/utils"
import {
accentColors,
baseColors,
getTheme,
type AccentColor,
type BaseColor,
} from "./theme"
type ThemeColor = BaseColor | AccentColor
type ThemeColorSwatch<T extends ThemeColor> = {
name: T
title: string
light: {
background: string
foreground: string
}
dark: {
background: string
foreground: string
}
}
function createSwatches<T extends ThemeColor>(
colors: readonly T[]
): ThemeColorSwatch<T>[] {
return colors.map((name) => {
const theme = getTheme(name)!
return {
name,
title: theme.title,
light: {
background: theme.cssVars.light.primary,
foreground: theme.cssVars.light["primary-foreground"],
},
dark: {
background: theme.cssVars.dark.primary,
foreground: theme.cssVars.dark["primary-foreground"],
},
}
})
}
export const baseColorSwatches = createSwatches(baseColors)
export const accentColorSwatches = createSwatches(accentColors)
export function ThemeColorPicker<T extends ThemeColor>({
swatches,
title,
value,
onChange,
}: {
swatches: readonly ThemeColorSwatch<T>[]
title: string
value: T
onChange: (color: T) => void
}) {
return (
<div className="relative rounded-lg border p-4">
<div className="absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground">
{title}
</div>
<div className="flex flex-wrap items-center gap-2">
{swatches.map((swatch) => (
<ThemeColorButton
key={swatch.name}
swatch={swatch}
active={value === swatch.name}
onClick={() => onChange(swatch.name)}
/>
))}
</div>
</div>
)
}
function ThemeColorButton({
swatch,
active,
onClick,
}: {
swatch: ThemeColorSwatch<ThemeColor>
active: boolean
onClick: () => void
}) {
const style = {
"--light-bg": swatch.light.background,
"--light-fg": swatch.light.foreground,
"--dark-bg": swatch.dark.background,
"--dark-fg": swatch.dark.foreground,
} as CSSProperties
return (
<button
type="button"
aria-label={swatch.title}
aria-pressed={active}
className={cn(
"h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
"border-(--light-bg) dark:border-(--dark-bg)",
"bg-(--light-bg) dark:bg-(--dark-bg)",
"text-(--light-fg) dark:text-(--dark-fg)",
"ring-0 aria-pressed:ring-2"
)}
style={style}
onClick={onClick}
>
{swatch.title}
</button>
)
}
@@ -0,0 +1,57 @@
import { MoonIcon, SunIcon } from "lucide-react"
import { useUiState } from "./ui-state"
import { Badge } from "@workspace/ui/components/badge"
import {
accentColorSwatches,
baseColorSwatches,
ThemeColorPicker,
} from "./theme-color-picker"
import { ThemePreview } from "./theme-preview"
import type { ThemeScheme } from "./theme"
export function ThemeConfigPanel({
scheme,
active,
description,
}: {
scheme: ThemeScheme
active: boolean
description: string
}) {
const stateKey = scheme === "light" ? "theme-light" : "theme-dark"
const [config, setConfig] = useUiState(stateKey)
return (
<div className="w-md rounded-xl border">
<div className="flex gap-4 border-b p-3">
{scheme === "light" ? (
<SunIcon className="size-5" />
) : (
<MoonIcon className="size-5" />
)}
<span>{scheme === "light" ? "浅色主题" : "深色主题"}</span>
{active && <Badge className="ml-auto">使</Badge>}
</div>
<div className="space-y-4 p-4">
<p>{description}</p>
<ThemePreview scheme={scheme} config={config} />
<ThemeColorPicker
swatches={baseColorSwatches}
title="基础颜色"
value={config.baseColor}
onChange={(baseColor) =>
setConfig((current) => ({ ...current, baseColor }))
}
/>
<ThemeColorPicker
swatches={accentColorSwatches}
title="主要颜色"
value={config.accentColor}
onChange={(accentColor) =>
setConfig((current) => ({ ...current, accentColor }))
}
/>
</div>
</div>
)
}
@@ -0,0 +1,103 @@
import type { CSSProperties } from "react"
import {
getTheme,
getThemeCssVariables,
type ThemeColorConfig,
type ThemeScheme,
} from "./theme"
import { cn } from "@workspace/ui/lib/utils"
import { useUiState } from "./ui-state"
export function ThemePreview({
scheme,
config,
className,
}: {
scheme: ThemeScheme
config: ThemeColorConfig
className?: string
}) {
const [isCompacted] = useUiState("main-compacted")
const themeVariables = getThemeCssVariables(scheme, config)
const baseTheme = getTheme(config.baseColor) ?? getTheme("neutral")!
const style = {
...Object.fromEntries(
Object.entries(themeVariables).map(([key, value]) => [`--${key}`, value])
),
"--preview-base": baseTheme.cssVars[scheme].primary,
} as CSSProperties
return (
<div className={cn(className, `ui:${scheme}`)} style={style}>
<div className="isolate h-52 max-w-103.5 rounded-lg bg-sidebar p-3 ring ring-border">
<div className="flex gap-2.5 [&>div]:bg-foreground/10">
<div className="size-5 rounded-sm" />
<div className="ml-auto size-5 rounded-sm" />
<div className="size-5 rounded-sm" />
<div className="size-5 rounded-sm" />
</div>
<div className="flex gap-3">
<div className="flex w-16 flex-col gap-1 py-5 [&>div]:bg-foreground/10">
<div className="h-2.5 w-full rounded-r-full" />
<div className="h-2.5 w-full rounded-r-full bg-primary!" />
<div className="h-2.5 w-full rounded-r-full" />
<div className="h-2.5 w-full rounded-r-full" />
<div className="mt-auto h-2.5 w-full rounded-r-full" />
</div>
<div className="flex-1 py-5">
<div className={cn("space-y-2.5", { "px-7.5": isCompacted })}>
<div className="flex h-2.5 items-center gap-2 [&>div]:bg-foreground/10">
<div className="h-2.5 w-3 rounded-sm" />
<div className="ml-auto size-2.5 rounded-sm" />
<div className="h-2.5 w-3.75 rounded-sm" />
<div className="h-2.5 w-5 rounded-sm bg-primary!" />
</div>
<div className="grid grid-cols-3 gap-2 [&>div]:rounded-sm [&>div]:bg-foreground/10">
<div className="flex h-18 items-end gap-2 p-2 compacted:gap-1">
<div className="h-6 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-4 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-11 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-8 w-2 rounded-t-sm bg-(--preview-base)/55" />
<div className="h-9 w-2 rounded-t-sm bg-(--preview-base)/55" />
</div>
<div className="flex h-18 flex-col items-stretch justify-between gap-1 p-2">
<div className="flex items-center gap-1 opacity-55">
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
<div className="ml-auto h-1 w-6 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
</div>
<div className="flex flex-wrap gap-1">
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55 compacted:w-4" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-2 rounded-xs bg-(--preview-base)/55 compacted:w-1" />
<div className="size-1 rounded-xs bg-(--preview-base)/55 compacted:w-5" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
<div className="size-1 rounded-xs bg-(--preview-base)/55 compacted:w-3" />
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55 compacted:hidden" />
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-6 rounded-sm bg-primary" />
<div className="h-2 w-6 rounded-sm bg-primary/75" />
</div>
</div>
<div className="h-18" />
<div className="h-10 flex-1" />
<div className="h-10 flex-1" />
<div className="h-10 flex-1" />
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest"
import {
accentColors,
baseColors,
buildThemeStyleSheet,
getTheme,
getThemeCssVariables,
themes,
} from "./theme"
describe("appearance themes", () => {
it("defines every selectable base and accent color exactly once", () => {
const selectableColors = [...baseColors, ...accentColors]
expect(themes.map((theme) => theme.name)).toEqual(selectableColors)
expect(new Set(selectableColors).size).toBe(selectableColors.length)
expect(themes.every((theme) => theme.title.length > 0)).toBe(true)
})
it("combines the base palette with the selected accent palette", () => {
const variables = getThemeCssVariables("light", {
baseColor: "stone",
accentColor: "rose",
})
expect(variables.background).toBe(
getTheme("stone")?.cssVars.light.background
)
expect(variables.primary).toBe(getTheme("rose")?.cssVars.light.primary)
})
it("builds explicit and system color-scheme rules", () => {
const styleSheet = buildThemeStyleSheet(
{ baseColor: "neutral", accentColor: "blue" },
{ baseColor: "zinc", accentColor: "violet" }
)
expect(styleSheet).toContain(":root,.ui\\:light{")
expect(styleSheet).toContain(".ui\\:dark{")
expect(styleSheet).toContain("@media (prefers-color-scheme:dark)")
expect(styleSheet).toContain("--background:")
expect(styleSheet).toContain("--primary:")
})
})
@@ -0,0 +1,58 @@
import {
themes,
type AccentColor,
type BaseColor,
type ThemeColorConfig,
type ThemeScheme,
} from "./themes"
export {
accentColors,
baseColors,
themes,
type AccentColor,
type BaseColor,
type Theme,
type ThemeColorConfig,
type ThemeScheme,
} from "./themes"
export const THEME_STYLE_ELEMENT_ID = "ui-theme-variables"
export const getTheme = (name: BaseColor | AccentColor) =>
themes.find((theme) => theme.name === name)
export function getThemeCssVariables(
scheme: ThemeScheme,
config: ThemeColorConfig
) {
const base = getTheme(config.baseColor) ?? getTheme("neutral")!
const accent = getTheme(config.accentColor) ?? getTheme("blue")!
return {
...base.cssVars[scheme],
...accent.cssVars[scheme],
}
}
function serializeThemeVariables(theme: Record<string, string>): string {
return Object.entries(theme)
.map(([key, value]) => `--${key}:${value}`)
.join(";")
}
export function buildThemeStyleSheet(
lightConfig: ThemeColorConfig,
darkConfig: ThemeColorConfig
) {
const light = serializeThemeVariables(
getThemeCssVariables("light", lightConfig)
)
const dark = serializeThemeVariables(getThemeCssVariables("dark", darkConfig))
return [
`:root,.ui\\:light{${light}}`,
`.ui\\:dark{${dark}}`,
`@media (prefers-color-scheme:dark){:root:not(.ui\\:light):not(.ui\\:dark){${dark}}}`,
].join("")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
import React from "react"
import {
type UiState,
type UiStateKey,
type UiStateUpdate,
type UiStateValue,
} from "./state"
import {
buildThemeStyleSheet,
THEME_STYLE_ELEMENT_ID,
type ThemeScheme,
} from "./theme"
const systemThemeQuery = "(prefers-color-scheme: dark)"
const themeStateKeys = new Set<UiStateKey>([
"theme-mode",
"theme-light",
"theme-dark",
])
let restoreThemeTransitionFrame: number | undefined
function disableThemeTransitionsTemporarily() {
if (typeof document === "undefined") return
const root = document.documentElement
root.classList.add("ui:theme-changing")
if (restoreThemeTransitionFrame !== undefined) {
cancelAnimationFrame(restoreThemeTransitionFrame)
}
restoreThemeTransitionFrame = requestAnimationFrame(() => {
restoreThemeTransitionFrame = requestAnimationFrame(() => {
root.classList.remove("ui:theme-changing")
restoreThemeTransitionFrame = undefined
})
})
}
function applyUiState(state: UiState) {
if (typeof document === "undefined") return
const root = document.documentElement
const resolvedTheme =
state["theme-mode"] === "system" ? getSystemTheme() : state["theme-mode"]
const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID)
if (themeStyle) {
themeStyle.textContent = buildThemeStyleSheet(
state["theme-light"],
state["theme-dark"]
)
}
root.classList.toggle("ui:compacted", state["main-compacted"])
root.classList.toggle("ui:collapsed", state["sidebar-collapsed"])
root.classList.toggle("ui:dark", resolvedTheme === "dark")
root.classList.toggle("ui:light", resolvedTheme === "light")
root.style.colorScheme = resolvedTheme
}
function subscribeToSystemTheme(listener: VoidFunction) {
const mediaQuery = window.matchMedia(systemThemeQuery)
mediaQuery.addEventListener("change", listener)
return () => mediaQuery.removeEventListener("change", listener)
}
function getSystemTheme(): ThemeScheme {
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
}
function getServerSystemTheme(): ThemeScheme {
return "light"
}
type UiStateStore = {
getSnapshot: () => UiState
getServerSnapshot: () => UiState
setValue: <K extends UiStateKey>(key: K, value: UiState[K]) => void
subscribe: (listener: VoidFunction) => VoidFunction
}
function createUiStateStore(initialState: UiState): UiStateStore {
let snapshot = initialState
const listeners = new Set<VoidFunction>()
return {
getSnapshot: () => snapshot,
getServerSnapshot: () => initialState,
setValue: (key, value) => {
if (snapshot[key] === value) return
snapshot = { ...snapshot, [key]: value }
listeners.forEach((listener) => listener())
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
export type UiStateChangeHandler = (
update: UiStateUpdate
) => Promise<void> | void
interface UiStateContextValue {
onStateChange?: UiStateChangeHandler
store: UiStateStore
}
const UiStateContext = React.createContext<UiStateContextValue | null>(null)
export interface UiStateProviderProps {
children: React.ReactNode
initialState: UiState
onStateChange?: UiStateChangeHandler
}
export function UiStateProvider({
children,
initialState,
onStateChange,
}: UiStateProviderProps) {
const storeRef = React.useRef<UiStateStore | null>(null)
if (!storeRef.current) {
storeRef.current = createUiStateStore(initialState)
}
const state = React.useSyncExternalStore(
storeRef.current.subscribe,
storeRef.current.getSnapshot,
storeRef.current.getServerSnapshot
)
React.useLayoutEffect(() => {
applyUiState(state)
}, [state])
React.useEffect(() => {
if (state["theme-mode"] !== "system") return
const mediaQuery = window.matchMedia(systemThemeQuery)
const applySystemTheme = () => {
disableThemeTransitionsTemporarily()
applyUiState(storeRef.current!.getSnapshot())
}
mediaQuery.addEventListener("change", applySystemTheme)
return () => mediaQuery.removeEventListener("change", applySystemTheme)
}, [state["theme-mode"]])
const contextValue = React.useMemo(
() => ({
onStateChange,
store: storeRef.current!,
}),
[onStateChange]
)
return (
<UiStateContext.Provider value={contextValue}>
{children}
</UiStateContext.Provider>
)
}
export function useUiState<K extends UiStateKey>(
key: K
): [UiStateValue<K>, React.Dispatch<React.SetStateAction<UiStateValue<K>>>] {
const context = React.useContext(UiStateContext)
if (!context) {
throw new Error("useUiState must be used within UiStateProvider")
}
const { onStateChange, store } = context
const state = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
)
const value = state[key] as UiStateValue<K>
const setValue = React.useCallback<
React.Dispatch<React.SetStateAction<UiStateValue<K>>>
>(
(action) => {
const currentValue = store.getSnapshot()[key] as UiStateValue<K>
const nextValue =
typeof action === "function" ? action(currentValue) : action
if (themeStateKeys.has(key)) {
disableThemeTransitionsTemporarily()
}
store.setValue(key, nextValue)
applyUiState(store.getSnapshot())
void onStateChange?.({ key, value: nextValue } as UiStateUpdate)
},
[key, onStateChange, store]
)
return [value, setValue] as const
}
export function useResolvedTheme(): ThemeScheme {
const [themeMode] = useUiState("theme-mode")
const systemTheme = React.useSyncExternalStore(
subscribeToSystemTheme,
getSystemTheme,
getServerSystemTheme
)
return themeMode === "system" ? systemTheme : themeMode
}
@@ -0,0 +1,3 @@
import { createPopoverHandle } from "@workspace/ui/components/popover"
export const chatPopoverHandle = createPopoverHandle<void>()
@@ -0,0 +1,13 @@
import * as React from "react"
import { PopoverTrigger } from "@workspace/ui/components/popover"
import { chatPopoverHandle } from "./chat-popover-handle"
type ChatPopoverTriggerProps = Omit<
React.ComponentProps<typeof PopoverTrigger>,
"handle"
>
export function ChatPopoverTrigger(props: ChatPopoverTriggerProps) {
return <PopoverTrigger {...props} handle={chatPopoverHandle} />
}
@@ -0,0 +1,43 @@
import { ItemGroup } from "@workspace/ui/components/item"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
} from "@workspace/ui/components/popover"
import { useOnScroll } from "../../hooks/use-on-scroll"
import { chatPopoverHandle } from "./chat-popover-handle"
import { ChatThreadItem } from "./chat-thread-item"
import type { ChatThread } from "./types"
export interface ChatPopoverProps {
threads: readonly ChatThread[]
title?: string
}
export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) {
useOnScroll(() => {
chatPopoverHandle.close()
})
return (
<Popover handle={chatPopoverHandle}>
<PopoverContent className="h-120 p-2" alignOffset={-50} showArrow>
<PopoverHeader className="px-4 pt-4">
<PopoverTitle className="text-lg">
{title}({threads.length})
</PopoverTitle>
<PopoverDescription />
</PopoverHeader>
<ItemGroup className="no-scrollbar scroll-fade gap-0! overflow-y-auto">
{threads.map((thread) => (
<ChatThreadItem key={thread.name} thread={thread} />
))}
</ItemGroup>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,45 @@
import * as React from "react"
import { Avatar, AvatarImage } from "@workspace/ui/components/avatar"
import {
Item,
ItemContent,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item"
import { useRippleRef } from "@workspace/ui/hooks/use-ripple"
import { cn } from "@workspace/ui/lib/utils"
import type { ChatThread } from "./types"
interface ChatThreadItemProps extends Omit<
React.ComponentProps<typeof Item>,
"children"
> {
thread: ChatThread
}
export function ChatThreadItem({
thread,
ref,
className,
...props
}: ChatThreadItemProps) {
const rippleRef = useRippleRef(ref)
return (
<Item
ref={rippleRef}
className={cn("relative cursor-pointer px-3 py-2 select-none", className)}
{...props}
>
<ItemMedia>
<Avatar size="lg">
<AvatarImage src={thread.avatarUrl} />
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{thread.name}</ItemTitle>
</ItemContent>
</Item>
)
}
@@ -0,0 +1,4 @@
export { ChatPopover } from "./chat-popover"
export type { ChatPopoverProps } from "./chat-popover"
export { ChatPopoverTrigger } from "./chat-popover-trigger"
export type { ChatThread } from "./types"
@@ -0,0 +1,5 @@
export interface ChatThread {
avatarUrl: string
name: string
presence?: "offline" | "online"
}
@@ -0,0 +1,450 @@
import { Link } from "@tanstack/react-router"
import * as React from "react"
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb"
import { Button } from "@workspace/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
import { type NavigationInfo, useNavigationStack } from "../navigation"
import { Icon } from "../../components/icon"
const MAX_VISIBLE_ENTRIES = 3
type BreadcrumbEntry = NavigationInfo
type BreadcrumbDisplayItem =
| {
entry: BreadcrumbEntry
kind: "entry"
}
| {
entries: readonly BreadcrumbEntry[]
kind: "menu"
}
function getDisplayItems(
entries: readonly BreadcrumbEntry[],
requestedVisibleCount: number
): BreadcrumbDisplayItem[] {
if (entries.length === 0) {
return []
}
const visibleCount = Math.max(
1,
Math.min(MAX_VISIBLE_ENTRIES, requestedVisibleCount, entries.length)
)
if (entries.length <= visibleCount) {
return entries.map((entry) => ({ entry, kind: "entry" }))
}
if (visibleCount === 1) {
return [
{ entries: entries.slice(0, -1), kind: "menu" },
{ entry: entries.at(-1)!, kind: "entry" },
]
}
const trailingEntries = entries.slice(-(visibleCount - 1))
const hiddenEntries = entries.slice(1, -(visibleCount - 1))
return [
{ entry: entries[0], kind: "entry" },
...(hiddenEntries.length > 0
? [{ entries: hiddenEntries, kind: "menu" as const }]
: []),
...trailingEntries.map((entry) => ({
entry,
kind: "entry" as const,
})),
]
}
function useVisibleEntryCount(entries: readonly BreadcrumbEntry[]) {
const breadcrumbRef = React.useRef<HTMLElement>(null)
const listRef = React.useRef<HTMLOListElement>(null)
const measurementRef = React.useRef<HTMLDivElement>(null)
const entriesKey = entries.map((entry) => entry.id).join("\u0000")
const maximumVisibleCount = Math.min(MAX_VISIBLE_ENTRIES, entries.length)
const [measurement, setMeasurement] = React.useState(() => ({
entriesKey,
visibleCount: maximumVisibleCount,
}))
React.useLayoutEffect(() => {
if (maximumVisibleCount === 0) {
setMeasurement({ entriesKey, visibleCount: 0 })
return
}
const breadcrumb = breadcrumbRef.current
const list = listRef.current
const measurement = measurementRef.current
if (!breadcrumb || !list || !measurement) {
return
}
const updateVisibleCount = () => {
const entryWidths = Array.from(
measurement.querySelectorAll<HTMLElement>(
"[data-breadcrumb-measure-entry]"
)
).map((element) => element.getBoundingClientRect().width)
const menuWidth =
measurement
.querySelector<HTMLElement>("[data-breadcrumb-measure-menu]")
?.getBoundingClientRect().width ?? 0
const separatorWidth =
measurement
.querySelector<HTMLElement>("[data-breadcrumb-measure-separator]")
?.getBoundingClientRect().width ?? 0
const listStyle = window.getComputedStyle(list)
const gap = Number.parseFloat(listStyle.columnGap || listStyle.gap) || 0
const availableWidth = breadcrumb.getBoundingClientRect().width
let nextVisibleCount = 1
for (
let candidateCount = maximumVisibleCount;
candidateCount >= 1;
candidateCount -= 1
) {
const candidateItems = getDisplayItems(entries, candidateCount)
const itemWidth = candidateItems.reduce((total, item) => {
if (item.kind === "menu") {
return total + menuWidth
}
const entryIndex = entries.indexOf(item.entry)
return total + (entryWidths[entryIndex] ?? 0)
}, 0)
const separatorCount = Math.max(0, candidateItems.length - 1)
const naturalWidth =
itemWidth + separatorWidth * separatorCount + gap * separatorCount * 2
if (naturalWidth <= availableWidth) {
nextVisibleCount = candidateCount
break
}
}
setMeasurement((current) => {
if (
current.entriesKey === entriesKey &&
current.visibleCount === nextVisibleCount
) {
return current
}
return { entriesKey, visibleCount: nextVisibleCount }
})
}
updateVisibleCount()
const observer = new ResizeObserver(updateVisibleCount)
observer.observe(breadcrumb)
observer.observe(measurement)
return () => observer.disconnect()
}, [entries, entriesKey, maximumVisibleCount])
const visibleCount =
measurement.entriesKey === entriesKey
? Math.min(measurement.visibleCount, maximumVisibleCount)
: maximumVisibleCount
return {
breadcrumbRef,
listRef,
measurementRef,
visibleCount,
}
}
function BreadcrumbEntryContent({ entry }: { entry: BreadcrumbEntry }) {
return (
<>
{entry.icon && <Icon data={entry.icon} className="size-4 shrink-0" />}
{entry.label}
</>
)
}
function BreadcrumbNavigationMenuItem({
currentId,
entry,
}: {
currentId?: string
entry: BreadcrumbEntry
}) {
const isCurrent = entry.id === currentId
if (entry.items?.length) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger
aria-current={isCurrent ? "page" : undefined}
className="aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary"
>
<BreadcrumbEntryContent entry={entry} />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{entry.items.map((childEntry) => (
<BreadcrumbNavigationMenuItem
key={childEntry.id}
currentId={currentId}
entry={childEntry}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
if (!entry.to) {
return (
<DropdownMenuItem
disabled
className="cursor-default data-disabled:opacity-100"
>
<BreadcrumbEntryContent entry={entry} />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
aria-current={isCurrent ? "page" : undefined}
className="aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary"
render={
entry.to === "/" ? (
<Link to="/" />
) : (
<Link to="/$" params={{ _splat: entry.to.replace(/^\/+/, "") }} />
)
}
>
<BreadcrumbEntryContent entry={entry} />
</DropdownMenuItem>
)
}
function BreadcrumbRouteLink({
currentId,
entry,
}: {
currentId?: string
entry: BreadcrumbEntry
}) {
if (entry.items?.length) {
return (
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center gap-1.5 outline-hidden transition-colors hover:text-foreground focus-visible:text-foreground">
<BreadcrumbEntryContent entry={entry} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" showArrow>
<DropdownMenuGroup>
{entry.items.map((childEntry) => (
<BreadcrumbNavigationMenuItem
key={childEntry.id}
currentId={currentId}
entry={childEntry}
/>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
if (!entry.to) {
return (
<span className="inline-flex items-center gap-1.5">
<BreadcrumbEntryContent entry={entry} />
</span>
)
}
const link =
entry.to === "/" ? (
<Link to="/" />
) : (
<Link to="/$" params={{ _splat: entry.to.replace(/^\/+/, "") }} />
)
return (
<BreadcrumbLink
className="inline-flex items-center gap-1.5 text-foreground"
render={link}
>
<BreadcrumbEntryContent entry={entry} />
</BreadcrumbLink>
)
}
function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button aria-label="打开面包屑菜单" size="icon-sm" variant="ghost" />
}
>
<BreadcrumbEllipsis />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
{entries.map((entry) => {
const content = (
<>
{entry.icon && (
<Icon data={entry.icon} className="size-4 shrink-0" />
)}
{entry.label}
</>
)
if (!entry.to) {
return (
<DropdownMenuItem
key={entry.id}
disabled
className="cursor-default data-disabled:opacity-100"
>
{content}
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={entry.id}
render={
entry.to === "/" ? (
<Link to="/" />
) : (
<Link
to="/$"
params={{ _splat: entry.to.replace(/^\/+/, "") }}
/>
)
}
>
{content}
</DropdownMenuItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function AppBreadcrumb() {
const navigationStack = useNavigationStack()
const currentId = navigationStack.at(-1)?.id
const entries = React.useMemo(
() => navigationStack.filter((entry) => entry.to !== "/"),
[navigationStack]
)
const { breadcrumbRef, listRef, measurementRef, visibleCount } =
useVisibleEntryCount(entries)
const displayItems = getDisplayItems(entries, visibleCount)
if (entries.length === 0) {
return null
}
return (
<Breadcrumb
ref={breadcrumbRef}
className="relative min-w-0 flex-1 overflow-hidden mobile:hidden"
>
<BreadcrumbList
ref={listRef}
className="flex-nowrap whitespace-nowrap text-foreground"
>
{displayItems.map((item, index) => {
const isCurrent = index === displayItems.length - 1
const key =
item.kind === "entry"
? item.entry.id
: `menu-${item.entries.map((entry) => entry.id).join("-")}`
return (
<React.Fragment key={key}>
{index > 0 && <BreadcrumbSeparator className="shrink-0" />}
<BreadcrumbItem className={isCurrent ? "min-w-0" : "shrink-0"}>
{item.kind === "menu" ? (
<BreadcrumbMenu entries={item.entries} />
) : isCurrent ? (
<BreadcrumbPage className="inline-flex min-w-0 items-center gap-1.5 text-primary">
{item.entry.icon && (
<Icon
data={item.entry.icon}
className="size-4 shrink-0"
/>
)}
<span className="truncate">{item.entry.label}</span>
</BreadcrumbPage>
) : (
<BreadcrumbRouteLink
currentId={currentId}
entry={item.entry}
/>
)}
</BreadcrumbItem>
</React.Fragment>
)
})}
</BreadcrumbList>
<div
ref={measurementRef}
aria-hidden="true"
className="pointer-events-none invisible absolute top-0 left-0 flex w-max items-center text-sm"
>
{entries.map((entry, index) => (
<span
key={entry.id}
data-breadcrumb-measure-entry={index}
className="inline-flex items-center gap-1.5 font-normal"
>
{entry.icon && (
<Icon data={entry.icon} className="size-4 shrink-0" />
)}
{entry.label}
</span>
))}
<span data-breadcrumb-measure-menu>
<Button tabIndex={-1} size="icon-sm" variant="ghost">
<BreadcrumbEllipsis />
</Button>
</span>
<BreadcrumbSeparator
data-breadcrumb-measure-separator
className="shrink-0"
/>
</div>
</Breadcrumb>
)
}
@@ -0,0 +1,246 @@
import * as React from "react"
import { Button } from "@workspace/ui/components/button"
import { Kbd } from "@workspace/ui/components/kbd"
import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint"
import { cn } from "@workspace/ui/lib/utils"
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { ChatPopover, ChatPopoverTrigger, type ChatThread } from "../chats"
import { Icon } from "../../components/icon"
import {
MobileNavigationSheet,
MobileNavigationSheetTrigger,
NavigationProvider,
type NavigationGroup,
} from "../navigation"
import {
NotificationCenterSheet,
NotificationCenterTrigger,
type UseNotificationsOptions,
} from "../notifications"
import {
BellIcon,
MessageMultiple01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons"
import { AppSidebar } from "./app-sidebar"
import {
headerActionIconClassName,
HeaderActionButton,
} from "./header-action-button"
import { useSidebarState } from "./sidebar-state"
import { UserMenu, UserMenuTrigger } from "./user-menu"
import { AppBreadcrumb } from "./app-breadcrumb"
import { Separator } from "@workspace/ui/components/separator"
import { AppQueryIndicator } from "./app-query-indicator"
export interface AppLayoutProps {
chatThreads: readonly ChatThread[]
children: React.ReactNode
navigationGroups: readonly NavigationGroup[]
notifications: UseNotificationsOptions
}
export function AppLayout({
chatThreads,
children,
navigationGroups,
notifications,
}: AppLayoutProps) {
return (
<NavigationProvider groups={navigationGroups}>
<MobileNavigationSheet groups={navigationGroups} />
<UserMenu />
<NotificationCenterSheet {...notifications} />
<ChatPopover threads={chatThreads} />
<div className="isolate min-h-svh w-svw">
<AppHeader />
<div className="relative z-1 flex min-h-svh w-svw items-start">
<AppHeaderBackground />
<AppSidebar groups={navigationGroups} />
<AppMain>{children}</AppMain>
</div>
</div>
</NavigationProvider>
)
}
function AppHeader() {
return (
<header className="sticky top-0 z-3 h-0">
<div className="flex h-(--ui-header-height) items-center">
<div className="flex w-(--ui-sidebar-width) shrink-0 items-center ps-7 pr-5 mobile:hidden tablet:justify-center tablet:p-0 collapsed:w-(--ui-sidebar-collapsed-width) collapsed:justify-center collapsed:p-0">
<span className="inline-flex size-10 [--palette-primary-main:var(--color-green-600)]">
<svg
width="100%"
height="100%"
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id="_r_5g_-1"
x1="152"
y1="167.79"
x2="65.523"
y2="259.624"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="var(--palette-primary-dark)"></stop>
<stop
offset="1"
stopColor="var(--palette-primary-main)"
></stop>
</linearGradient>
<linearGradient
id="_r_5g_-2"
x1="86"
y1="128"
x2="86"
y2="384"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="var(--palette-primary-light)"></stop>
<stop
offset="1"
stopColor="var(--palette-primary-main)"
></stop>
</linearGradient>
<linearGradient
id="_r_5g_-3"
x1="402"
y1="288"
x2="402"
y2="384"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="var(--palette-primary-light)"></stop>
<stop
offset="1"
stopColor="var(--palette-primary-main)"
></stop>
</linearGradient>
</defs>
<path
fill="url(#_r_5g_-1)"
d="M86.352 246.358C137.511 214.183 161.836 245.017 183.168 285.573C165.515 317.716 153.837 337.331 148.132 344.418C137.373 357.788 125.636 367.911 111.202 373.752C80.856 388.014 43.132 388.681 14 371.048L86.352 246.358Z"
></path>
<path
fill="url(#_r_5g_-2)"
fillRule="evenodd"
clipRule="evenodd"
d="M444.31 229.726C398.04 148.77 350.21 72.498 295.267 184.382C287.751 198.766 282.272 226.719 270 226.719V226.577C257.728 226.577 252.251 198.624 244.735 184.24C189.79 72.356 141.96 148.628 95.689 229.584C92.207 235.69 88.862 241.516 86 246.58C192.038 179.453 183.11 382.247 270 383.858V384C356.891 382.389 347.962 179.595 454 246.72C451.139 241.658 447.794 235.832 444.31 229.726Z"
></path>
<path
fill="url(#_r_5g_-3)"
fillRule="evenodd"
clipRule="evenodd"
d="M450 384C476.509 384 498 362.509 498 336C498 309.491 476.509 288 450 288C423.491 288 402 309.491 402 336C402 362.509 423.491 384 450 384Z"
></path>
</svg>
</span>
</div>
<div className="z-10 w-0 mobile:hidden">
<SidebarCollapseButton />
</div>
<div className="flex h-full flex-1 items-center gap-2 px-10 mobile:px-6">
<MobileNavigationSheetTrigger />
<div className="flex flex-1 items-center">
<AppQueryIndicator className="-ml-2.5" />
<Separator
orientation="vertical"
className="ms-1.5 me-4 h-3 shrink-0 self-center! last:hidden"
/>
<AppBreadcrumb />
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<GlobalSearchTrigger />
<NotificationCenterTrigger
render={<HeaderActionButton icon={BellIcon} showIndicator />}
/>
<ChatPopoverTrigger
render={
<HeaderActionButton
icon={MessageMultiple01Icon}
showIndicator
/>
}
/>
<UserMenuTrigger className="ml-4" />
</div>
</div>
</div>
</header>
)
}
function AppHeaderBackground() {
return (
<div
aria-hidden="true"
className="pointer-events-none sticky top-0 z-2 h-0 w-0"
>
<div className="absolute top-0 left-0 h-(--ui-header-height) w-svw bg-sidebar/90 backdrop-blur-xs">
<div className="scroll-top-divider absolute right-0 bottom-0 left-(--ui-sidebar-width) -z-1 h-px origin-bottom scale-y-50 bg-sidebar-border" />
</div>
</div>
)
}
function AppMain({ children }: { children?: React.ReactNode }) {
return (
<main className="relative z-1 min-h-svh flex-1 px-10 pt-[calc(var(--ui-header-height)+var(--ui-content-top-gap))] pb-10 mobile:px-6 mobile:pb-6">
<div className="mx-auto min-h-full w-full desktop:compacted:max-w-7xl">
{children}
</div>
</main>
)
}
function GlobalSearchTrigger() {
return (
<HeaderActionButton
className="gap-2 bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_2%)] not-mobile:h-9 not-mobile:w-fit not-mobile:ps-2 not-mobile:pe-1.75 not-mobile:hover:scale-100 mobile:bg-transparent mobile:hover:bg-muted"
variant="secondary"
>
<Icon
data={Search01Icon}
className={cn(
"pointer-event-none size-6 transition-all not-mobile:hidden",
headerActionIconClassName
)}
/>
<SearchIcon
className="text-muted-foreground mobile:hidden"
strokeWidth={2.5}
/>
<Kbd className="rounded-[4px] bg-white text-black shadow-xs mobile:hidden">
K
</Kbd>
</HeaderActionButton>
)
}
function SidebarCollapseButton() {
const { isCollapsed, setCollapsed } = useSidebarState()
const isTablet = useIsTablet()
const ChevronIcon = isCollapsed ? ChevronRightIcon : ChevronLeftIcon
return (
<Button
className="relative size-6.5 -translate-x-1/2 rounded-full border-none! bg-sidebar text-muted-foreground shadow-none backdrop-blur-xs disabled:cursor-not-allowed disabled:opacity-100"
variant="outline"
onClick={() => setCollapsed(!isCollapsed)}
disabled={isTablet}
>
<ChevronIcon className="group-disabled/button:stroke-sidebar-border" />
<span
className="pointer-events-none absolute top-0 left-0 z-2 block size-[200%] origin-top-left scale-50 rounded-full border border-border dark:border-input"
aria-hidden="true"
/>
</Button>
)
}
@@ -0,0 +1,47 @@
import { useRefresh } from "../../hooks/use-refresh"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { HeaderActionButton } from "./header-action-button"
import { ReloadIcon } from "@hugeicons/core-free-icons"
import { cn } from "@workspace/ui/lib/utils"
export function AppQueryIndicator({ className }: { className?: string }) {
const { canRefresh, isRefreshing, refresh } = useRefresh()
if (isRefreshing) {
return (
<span
className={cn(
"inline-flex size-10 shrink-0 items-center justify-center",
className
)}
>
<span className="size-4.5 animate-spin rounded-full border-2 border-foreground/10 border-t-primary" />
</span>
)
}
return (
<Tooltip>
<TooltipTrigger
render={
<HeaderActionButton
aria-label="刷新当前页面数据"
className={cn("[&_svg]:size-4", className)}
disabled={!canRefresh}
icon={ReloadIcon}
onClick={refresh}
/>
}
/>
<TooltipContent showArrow>
<br />
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,35 @@
import { useLocation } from "@tanstack/react-router"
import { PrimaryNavigation } from "../navigation/primary-navigation"
import {
resolveNavigationRouteState,
type GetNavigationRouteState,
} from "../navigation/route-state"
import type { NavigationGroup } from "../navigation/types"
import { useIsSidebarCompact } from "./sidebar-state"
export function AppSidebar({ groups }: { groups: readonly NavigationGroup[] }) {
const { pathname } = useLocation()
const isCompact = useIsSidebarCompact()
const getRouteState: GetNavigationRouteState = (to) =>
resolveNavigationRouteState(pathname, to)
return (
<aside className="sticky top-0 z-3 h-svh w-(--ui-sidebar-width) shrink-0 mobile:hidden collapsed:w-(--ui-sidebar-collapsed-width)">
<div className="relative flex size-full flex-col items-stretch pt-(--ui-header-height)">
<div className="no-scrollbar min-h-0 flex-1 scroll-fade overflow-y-auto">
<PrimaryNavigation
items={groups}
isCompact={isCompact}
getRouteState={getRouteState}
/>
</div>
<div className="shrink-0 p-4 tablet:px-2 not-mobile:collapsed:px-2" />
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 -z-1 w-px origin-right scale-x-50 bg-sidebar-border"
/>
</div>
</aside>
)
}
@@ -0,0 +1,35 @@
import { Icon, type IconData } from "../../components/icon"
import { Button, type ButtonProps } from "@workspace/ui/components/button"
import { cn } from "@workspace/ui/lib/utils"
export const headerActionIconClassName =
"transition-all transform origin-center group-hover/button:scale-105"
type HeaderActionButtonProps = ButtonProps & {
icon?: IconData
showIndicator?: boolean
}
export function HeaderActionButton({
children,
className,
icon,
showIndicator,
...props
}: HeaderActionButtonProps) {
return (
<Button variant="ghost" size="icon-lg" className={cn(className)} {...props}>
{icon ? (
<Icon data={icon} className={cn(headerActionIconClassName)} />
) : (
children
)}
{showIndicator && (
<span
aria-hidden="true"
className="absolute top-1 right-1 inline-flex size-2 animate-pulse rounded-md bg-primary"
/>
)}
</Button>
)
}
@@ -0,0 +1,14 @@
export { AppBreadcrumb } from "./app-breadcrumb"
export { AppLayout, type AppLayoutProps } from "./app-layout"
export { AppQueryIndicator } from "./app-query-indicator"
export { AppSidebar } from "./app-sidebar"
export {
headerActionIconClassName,
HeaderActionButton,
} from "./header-action-button"
export {
SidebarStateProvider,
useIsSidebarCompact,
useSidebarState,
} from "./sidebar-state"
export { UserMenu, UserMenuTrigger } from "./user-menu"
@@ -0,0 +1,53 @@
import * as React from "react"
import { useUiState } from "../appearance/ui-state"
import { useBreakpoint } from "@workspace/ui/hooks/use-breakpoint"
interface SidebarStateContextValue {
isCollapsed: boolean
setCollapsed: (collapsed: boolean) => void
}
const SidebarStateContext =
React.createContext<SidebarStateContextValue | null>(null)
export function SidebarStateProvider({
children,
}: {
children: React.ReactNode
}) {
const [isCollapsed, setCollapsedPreference] = useUiState("sidebar-collapsed")
const setCollapsed = React.useCallback(
(collapsed: boolean) => setCollapsedPreference(collapsed),
[setCollapsedPreference]
)
const contextValue = React.useMemo(
() => ({ isCollapsed, setCollapsed }),
[isCollapsed]
)
return (
<SidebarStateContext.Provider value={contextValue}>
{children}
</SidebarStateContext.Provider>
)
}
export function useSidebarState() {
const context = React.useContext(SidebarStateContext)
if (!context) {
throw new Error("useSidebarState must be used within SidebarStateProvider.")
}
return context
}
export function useIsSidebarCompact() {
const breakpoint = useBreakpoint()
const { isCollapsed } = useSidebarState()
return breakpoint === "tablet" || (breakpoint === "desktop" && isCollapsed)
}
@@ -0,0 +1,243 @@
import { formatForDisplay } from "@tanstack/react-hotkeys"
import { Button, type ButtonProps } from "@workspace/ui/components/button"
import {
createDialogHandle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
type DialogHandle,
} from "@workspace/ui/components/dialog"
import { Kbd } from "@workspace/ui/components/kbd"
import {
createPopoverHandle,
Popover,
PopoverContent,
PopoverTrigger,
type PopoverHandle,
} from "@workspace/ui/components/popover"
import {
useIsMobile,
useOnBreakpoint,
} from "@workspace/ui/hooks/use-breakpoint"
import { cn } from "@workspace/ui/lib/utils"
import {
getNavigationCommandProps,
navigationActions,
type NavigationAction,
} from "../../lib/command-actions"
import {
headerActionIconClassName,
HeaderActionButton,
} from "./header-action-button"
import { useOnScroll } from "../../hooks/use-on-scroll"
import { Icon, type IconData } from "../../components/icon"
import {
KeyboardIcon,
LogoutCircle02Icon,
ShieldKeyIcon,
SwatchIcon,
} from "@hugeicons/core-free-icons"
interface UserMenuItem {
action?: NavigationAction
shortcut?: string
icon: IconData
label: string
}
interface UserMenuTriggerProps {
className?: string
}
const userMenuItems: readonly UserMenuItem[] = [
{
action: navigationActions.appearance,
icon: SwatchIcon,
label: "界面外观",
shortcut: "Mod+,",
},
{
icon: KeyboardIcon,
label: "按键映射",
},
{
icon: ShieldKeyIcon,
label: "账号密码",
shortcut: "Mod+P",
},
]
let userMenuHandles:
| {
dialog: DialogHandle<void>
popover: PopoverHandle<void>
}
| undefined
function getOrCreateUserMenuHandles() {
userMenuHandles ??= {
dialog: createDialogHandle(),
popover: createPopoverHandle(),
}
return userMenuHandles
}
export function UserMenuTrigger({ className }: UserMenuTriggerProps) {
return useIsMobile() ? (
<UserMenuDialogTrigger className={className} />
) : (
<UserMenuPopoverTrigger className={className} />
)
}
export function UserMenu() {
useOnBreakpoint((breakpoint) => {
const { popover, dialog } = getOrCreateUserMenuHandles()
if (breakpoint === "mobile") {
if (popover.isOpen) {
popover.close()
}
} else if (dialog.isOpen) {
dialog.close()
}
})
return useIsMobile() ? <UserMenuDialog /> : <UserMenuPopover />
}
function UserMenuPopoverTrigger({ className }: UserMenuTriggerProps) {
const { popover } = getOrCreateUserMenuHandles()
return (
<PopoverTrigger
handle={popover}
className={className}
render={<UserMenuTriggerButton />}
/>
)
}
function UserMenuTriggerButton({ className, ...props }: ButtonProps) {
return (
<HeaderActionButton
className={cn(
"overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:z-999 after:rounded-md after:border after:border-foreground/35",
className
)}
{...props}
>
<img
className={cn("pointer-events-none", headerActionIconClassName)}
src="https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-25.webp"
/>
</HeaderActionButton>
)
}
function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
return (
<div className="w-72">
<div className="flex flex-col items-center pt-10 pb-6">
<img
className="pointer-events-none size-21 rounded-full border border-foreground/35"
src="https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-25.webp"
/>
<h6 className="mt-4 font-semibold">Jaydon Frankie</h6>
<p className="mt-1 text-xs text-muted-foreground">demo@minimals.cc</p>
</div>
<div className="border-t border-dashed border-border p-2">
<ul className="space-y">
{userMenuItems.map((item) => (
<li key={item.label}>
<Button
className="w-full justify-start font-normal"
size="lg"
variant="ghost"
nativeButton={item.action ? true : false}
render={item.action ? undefined : <a />}
onClick={onItemSelect}
{...(item.action
? getNavigationCommandProps(item.action)
: undefined)}
>
<Icon data={item.icon} size="sm" />
{item.label}
{item.shortcut && <MenuShortcut shortcut={item.shortcut} />}
</Button>
</li>
))}
</ul>
</div>
<div className="rounded-b-md border-t p-2">
<Button className="w-full font-normal" variant="destructive" size="lg">
<Icon data={LogoutCircle02Icon} size="sm" />
退
<MenuShortcutSequence shortcuts={["Q", "Q", "Q"]} />
</Button>
</div>
</div>
)
}
function UserMenuPopover() {
const { popover } = getOrCreateUserMenuHandles()
useOnScroll(() => {
popover.close()
})
return (
<Popover handle={popover}>
<PopoverContent align="end" className="w-fit rounded-lg p-0" showArrow>
<UserMenuContent onItemSelect={() => popover.close()} />
</PopoverContent>
</Popover>
)
}
function UserMenuDialogTrigger({ className }: UserMenuTriggerProps) {
const { dialog } = getOrCreateUserMenuHandles()
return (
<DialogTrigger
handle={dialog}
className={className}
render={<UserMenuTriggerButton />}
/>
)
}
function UserMenuDialog() {
const { dialog } = getOrCreateUserMenuHandles()
return (
<Dialog handle={dialog}>
<DialogContent className="w-fit p-0">
<DialogHeader className="sr-only">
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<UserMenuContent onItemSelect={() => dialog.close()} />
</DialogContent>
</Dialog>
)
}
function MenuShortcut({ shortcut }: { shortcut: string }) {
return (
<Kbd className="ml-auto group-hover/button:bg-foreground group-hover/button:text-background">
{formatForDisplay(shortcut)}
</Kbd>
)
}
function MenuShortcutSequence({ shortcuts }: { shortcuts: readonly string[] }) {
return (
<Kbd className="ml-auto group-hover/button:bg-foreground group-hover/button:text-background">
{shortcuts.map((shortcut) => formatForDisplay(shortcut)).join(" → ")}
</Kbd>
)
}
@@ -0,0 +1,67 @@
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@workspace/ui/components/dropdown-menu"
import { Icon } from "../../components/icon"
import { resolveNavigationItemIcon } from "./item-icon"
import { NavigationBadge, NavigationLink } from "./navigation-button"
import type { GetNavigationRouteState } from "./route-state"
import type { NavigationItem } from "./types"
interface SidebarFlyoutMenuItemsProps {
items: readonly NavigationItem[]
getRouteState: GetNavigationRouteState
}
export function SidebarFlyoutMenuItems({
items,
getRouteState,
}: SidebarFlyoutMenuItemsProps) {
return items.map((item) => {
const icon = resolveNavigationItemIcon(item)
const iconElement = icon ? (
<Icon data={icon} className="size-5 shrink-0" />
) : null
const routeState = getRouteState(item.to)
if (item.items?.length) {
return (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger
aria-current={routeState.isCurrent ? "page" : undefined}
data-route-active={routeState.isActive ? "" : undefined}
className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!"
>
{iconElement}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<NavigationBadge className="ms-auto" itemId={item.id} />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="grid w-fit max-w-(--available-width) min-w-44 gap-0.5">
<SidebarFlyoutMenuItems
items={item.items}
getRouteState={getRouteState}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return (
<DropdownMenuItem
key={item.id}
aria-current={routeState.isCurrent ? "page" : undefined}
data-route-active={routeState.isActive ? "" : undefined}
className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!"
render={item.to ? <NavigationLink to={item.to} /> : undefined}
>
{iconElement}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<NavigationBadge className="ms-auto" itemId={item.id} />
</DropdownMenuItem>
)
})
}
@@ -0,0 +1,19 @@
export {
MobileNavigationSheet,
MobileNavigationSheetTrigger,
} from "./mobile-navigation"
export { resolveNavigationItemIcon } from "./item-icon"
export { PrimaryNavigation } from "./primary-navigation"
export { resolveNavigationRouteState } from "./route-state"
export {
NavigationProvider,
type NavigationProviderProps,
useNavigationInfo,
useNavigationStack,
} from "./use-navigation"
export type {
GetNavigationRouteState,
NavigationRouteState,
} from "./route-state"
export type { NavigationInfo, NavigationKey } from "./use-navigation"
export type { NavigationGroup, NavigationItem } from "./types"
@@ -0,0 +1,10 @@
import type { IconData } from "../../components/icon"
import type { NavigationItem } from "./types"
export function resolveNavigationItemIcon(
item: NavigationItem,
visibleByDefault = false
): IconData | undefined {
return (item.showIcon ?? visibleByDefault) ? item.icon : undefined
}
@@ -0,0 +1,175 @@
import { useLocation } from "@tanstack/react-router"
import type { ButtonProps } from "@workspace/ui/components/button"
import { Separator } from "@workspace/ui/components/separator"
import {
createSheetHandle,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet"
import { useOnBreakpoint } from "@workspace/ui/hooks/use-breakpoint"
import { cn, separate } from "@workspace/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react"
import { HeaderActionButton } from "../layout/header-action-button"
import {
NavigationBadge,
NavigationButton,
NavigationLink,
} from "./navigation-button"
import { resolveNavigationItemIcon } from "./item-icon"
import { PrimaryNavigation } from "./primary-navigation"
import {
resolveNavigationRouteState,
type GetNavigationRouteState,
} from "./route-state"
import type { NavigationGroup } from "./types"
import { Icon } from "../../components/icon"
import { Menu02Icon } from "@hugeicons/core-free-icons"
const mobileNavigationSheetHandle = createSheetHandle<void>()
export function MobileNavigationSheetTrigger({
className,
...props
}: ButtonProps) {
return (
<SheetTrigger
handle={mobileNavigationSheetHandle}
className={cn("-ml-2 hidden mobile:inline-flex", className)}
render={<HeaderActionButton aria-label="打开导航" {...props} />}
>
<Icon
data={Menu02Icon}
strokeWidth={2.25}
className="size-7 [&_path]:transition-all group-hover/button:[&_path]:not-first:not-last:opacity-40 [&_path]:first:opacity-40 group-hover/button:[&_path]:first:translate-x-1 group-hover/button:[&_path]:first:opacity-100 [&_path]:last:opacity-60 group-hover/button:[&_path]:last:translate-x-2 group-hover/button:[&_path]:last:opacity-100"
/>
</SheetTrigger>
)
}
export interface MobileNavigationSheetProps {
groups: readonly NavigationGroup[]
}
export function MobileNavigationSheet({ groups }: MobileNavigationSheetProps) {
useOnBreakpoint((breakpoint) => {
if (breakpoint !== "mobile" && mobileNavigationSheetHandle.isOpen) {
mobileNavigationSheetHandle.close()
}
})
const hasNestedItems = groups.some((group) =>
group.items.some((item) => !!item.items?.length)
)
const { pathname } = useLocation()
const getRouteState: GetNavigationRouteState = (to) =>
resolveNavigationRouteState(pathname, to)
return (
<Sheet handle={mobileNavigationSheetHandle}>
<SheetContent className="max-w-72 gap-0 bg-popover/90" side="left">
<SheetHeader className="text-left">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Navigate through the app.</SheetDescription>
</SheetHeader>
<div
className={cn(
"no-scrollbar min-h-0 flex-1 scroll-fade overflow-y-auto",
hasNestedItems ? "p-1" : "p-4"
)}
>
<FlatNavigationGroupList
getRouteState={getRouteState}
className="p-4"
items={groups}
/>
{hasNestedItems ? (
<PrimaryNavigation
getRouteState={getRouteState}
className="w-full"
items={groups}
/>
) : (
<FlatNavigationGroupList
getRouteState={getRouteState}
items={groups}
/>
)}
</div>
<div className="h-4" />
</SheetContent>
</Sheet>
)
}
interface FlatNavigationGroupListProps {
className?: string
items: readonly NavigationGroup[]
getRouteState: GetNavigationRouteState
}
function FlatNavigationGroupList({
className,
items,
getRouteState,
}: FlatNavigationGroupListProps) {
return (
<div className={cn("flex flex-col gap-1 space-y-7", className)}>
{items.map((group) => (
<div key={group.id}>
<div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden">
{group.label}
</div>
<div className="rounded-md ring ring-sidebar-border">
{separate({
items: group.items,
iterator: (item) => {
const routeState = getRouteState(item.to)
return (
<NavigationButton
key={`item-${item.id}`}
label={item.label}
layout="inline"
icon={resolveNavigationItemIcon(
item,
group.showIcon ?? true
)}
className="group h-11 w-full rounded-none ps-3 pe-2 first:rounded-t-md last:rounded-b-md"
{...routeState}
render={
item.to ? <NavigationLink to={item.to} /> : undefined
}
trailing={
<>
<NavigationBadge
className="mx-2 tablet:hidden not-mobile:collapsed:hidden"
itemId={item.id}
/>
<span className="ml-auto text-muted-foreground/80">
<ChevronRightIcon className="size-4" />
</span>
</>
}
/>
)
},
separator: (index) => (
<Separator
key={`separator-${index}`}
className="ms-12 me-3 w-auto!"
/>
),
})}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,224 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { Link } from "@tanstack/react-router"
import * as React from "react"
import { Badge } from "@workspace/ui/components/badge"
import { useRippleRef } from "@workspace/ui/hooks/use-ripple"
import { cn } from "@workspace/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react"
import { Icon, type IconData } from "../../components/icon"
export interface NavigationButtonProps extends Omit<
useRender.ComponentProps<"button", {}>,
"className" | "style"
> {
icon?: IconData
isActive: boolean
isCurrent: boolean
label: string
layout?: "inline" | "stacked" | "responsive"
className?: string | (() => string | undefined) | undefined
style?:
React.CSSProperties | (() => React.CSSProperties | undefined) | undefined
trailing?: React.ReactNode | (() => React.ReactNode) | undefined
}
type NavigationLinkProps = Omit<React.ComponentPropsWithRef<"a">, "href"> & {
to: string
}
export function NavigationLink({ to, ...props }: NavigationLinkProps) {
if (to === "/") {
return <Link {...props} to="/" />
}
return <Link {...props} to="/$" params={{ _splat: to.replace(/^\/+/, "") }} />
}
export function NavigationButton({
icon,
isActive,
isCurrent,
label,
layout = "inline",
trailing,
className,
style,
render,
ref,
...props
}: NavigationButtonProps) {
const rippleRef = useRippleRef(ref)
const trailingNode = typeof trailing === "function" ? trailing() : trailing
const hasTrailingContent = React.Children.count(trailingNode) > 0
const baseButtonProps: useRender.ElementProps<"button"> = {
className: cn(
"group/button relative items-center rounded-md [&>span]:inline-flex [&>span]:items-center",
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground data-route-active:bg-primary/10 data-route-active:text-primary dark:hover:bg-muted/50 dark:aria-expanded:bg-muted/50 dark:data-route-active:bg-muted/50",
layout === "stacked"
? [
"grid grid-cols-[minmax(0,1fr)_auto_auto_minmax(0,1fr)]",
"grid-rows-[auto_auto] content-center gap-x-0 gap-y-1",
]
: layout === "responsive"
? [
"flex flex-row gap-3",
"tablet:grid tablet:grid-cols-[minmax(0,1fr)_auto_auto_minmax(0,1fr)]",
"tablet:grid-rows-[auto_auto] tablet:content-center tablet:gap-x-0 tablet:gap-y-1",
"not-mobile:collapsed:grid",
"not-mobile:collapsed:grid-cols-[minmax(0,1fr)_auto_auto_minmax(0,1fr)]",
"not-mobile:collapsed:grid-rows-[auto_auto]",
"not-mobile:collapsed:content-center",
"not-mobile:collapsed:gap-x-0 not-mobile:collapsed:gap-y-1",
]
: "flex flex-row gap-3",
typeof className === "function" ? className() : className
),
style: typeof style === "function" ? style() : style,
type: "button",
children: (
<React.Fragment>
{icon && (
<span
className={cn(
"shrink-0 [&_svg]:size-5",
layout === "stacked"
? "col-start-2 row-start-1 self-end [&_svg]:size-5.5"
: layout === "responsive"
? [
"tablet:col-start-2 tablet:row-start-1",
"tablet:self-end",
"not-mobile:collapsed:col-start-2",
"not-mobile:collapsed:row-start-1",
"not-mobile:collapsed:self-end",
"tablet:[&_svg]:size-5.5",
"not-mobile:collapsed:[&_svg]:size-5.5",
]
: undefined
)}
>
<Icon data={icon} />
</span>
)}
<span
className={cn(
"flex-1 origin-top truncate whitespace-nowrap",
layout === "stacked"
? [
"col-start-1 col-end-5 row-start-2 justify-self-center",
"scale-85 text-center text-xs",
]
: layout === "responsive"
? [
"tablet:col-start-1 tablet:col-end-5 tablet:row-start-2",
"tablet:justify-self-center tablet:text-center tablet:text-xs",
"not-mobile:collapsed:col-start-1",
"not-mobile:collapsed:col-end-5",
"not-mobile:collapsed:row-start-2",
"not-mobile:collapsed:justify-self-center",
"not-mobile:collapsed:text-center",
"not-mobile:collapsed:text-xs",
"tablet:scale-85",
"not-mobile:collapsed:scale-85",
]
: undefined
)}
>
{label}
</span>
{hasTrailingContent && (
<span
className={cn(
"shrink-0 whitespace-nowrap *:shrink-0",
layout === "stacked"
? "col-start-3 row-start-1 h-5.5 w-0 max-w-0 min-w-0 self-end justify-self-start overflow-visible"
: layout === "responsive"
? [
"tablet:col-start-3 tablet:row-start-1 tablet:w-0",
"tablet:max-w-0 tablet:min-w-0",
"tablet:h-5.5 tablet:self-end tablet:justify-self-start tablet:overflow-visible",
"not-mobile:collapsed:col-start-3",
"not-mobile:collapsed:row-start-1",
"not-mobile:collapsed:h-5.5",
"not-mobile:collapsed:w-0",
"not-mobile:collapsed:min-w-0",
"not-mobile:collapsed:max-w-0",
"not-mobile:collapsed:self-end",
"not-mobile:collapsed:justify-self-start",
"not-mobile:collapsed:overflow-visible",
]
: undefined
)}
>
{trailingNode}
</span>
)}
</React.Fragment>
),
"aria-label": label,
}
return useRender({
defaultTagName: "button",
ref: rippleRef,
render,
props: mergeProps<"button">(baseButtonProps, props, {
"aria-current": isCurrent ? "page" : undefined,
}),
state: { layout, "route-active": isActive },
})
}
export function NavigationDisclosureIcon({
staticWhenCompact = false,
}: {
staticWhenCompact?: boolean
}) {
return (
<span
className={cn(
"inline-flex shrink-0 items-center",
staticWhenCompact && [
"tablet:group-data-panel-open/button:-rotate-90",
"not-mobile:collapsed:group-data-panel-open/button:-rotate-90",
]
)}
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-100 ease-[ease-out] motion-reduce:transition-none",
"group-data-panel-open/button:rotate-90",
staticWhenCompact && [
"tablet:transition-none",
"not-mobile:collapsed:transition-none",
]
)}
/>
</span>
)
}
type NavigationBadgeProps = Omit<
React.ComponentProps<typeof Badge>,
"children"
> & {
itemId: string
}
function getNavigationBadgeValue(_itemId: string) {
// TODO 暂时返回空的
return ""
}
export function NavigationBadge({ itemId, ...props }: NavigationBadgeProps) {
const badgeValue = getNavigationBadgeValue(itemId)
if (!badgeValue) {
return null
}
return <Badge {...props}>{badgeValue}</Badge>
}
@@ -0,0 +1,109 @@
import { Accordion } from "@base-ui/react/accordion"
import { cn } from "@workspace/ui/lib/utils"
import {
NavigationBadge,
NavigationButton,
NavigationDisclosureIcon,
NavigationLink,
} from "./navigation-button"
import { resolveNavigationItemIcon } from "./item-icon"
import type { GetNavigationRouteState } from "./route-state"
import type { NavigationItem } from "./types"
interface InlineNestedNavigationListProps {
className?: string
getRouteState: GetNavigationRouteState
items: readonly NavigationItem[]
}
export function InlineNestedNavigationList({
className,
items,
getRouteState,
}: InlineNestedNavigationListProps) {
const hasNestedItems = items.some((item) => !!item.items?.length)
const listElement = (
<div className={cn("w-full pb-1 pl-6", className)}>
<ul
className={cn(
"relative space-y-1 py-1 pl-2.75",
"before:absolute before:top-0 before:bottom-7 before:left-0",
"before:-translate-x-px",
"before:w-0.5",
"before:bg-[#edeff2]",
"dark:before:bg-[#282f37]"
)}
>
{items.map((item) => {
const hasChildItems = !!item.items?.length
const routeState = getRouteState(item.to)
let itemElement = (
<NavigationButton
label={item.label}
icon={resolveNavigationItemIcon(item)}
className="h-9 w-full ps-3.25 pe-2"
layout="inline"
{...routeState}
render={
!hasChildItems && item.to ? (
<NavigationLink to={item.to} />
) : undefined
}
trailing={
<>
<NavigationBadge
itemId={item.id}
className={hasNestedItems ? "mx-2" : "ms-2"}
/>
{hasChildItems && <NavigationDisclosureIcon />}
</>
}
/>
)
if (hasChildItems) {
itemElement = (
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger render={itemElement} />
</Accordion.Header>
<Accordion.Panel className="h-(--accordion-panel-height) overflow-hidden transition-[height] duration-150 ease-[ease-out] data-ending-style:h-0 data-starting-style:h-0">
<InlineNestedNavigationList
className=""
items={item.items!}
getRouteState={getRouteState}
/>
</Accordion.Panel>
</Accordion.Item>
)
}
return (
<li
key={item.id}
className={cn(
"relative before:pointer-events-none before:absolute before:top-1.75 before:-left-3 before:size-3",
"before:bg-[#edeff2] dark:before:bg-[#282f37]",
`before:[mask:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='12'%20height='12'%20viewBox='0%200%2012%2012'%20fill='none'%3E%3Cpath%20d='M1%201v3a7%207%200%200%200%207%207h3'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E")_center/100%_100%_no-repeat]`
)}
>
{itemElement}
</li>
)
})}
</ul>
</div>
)
if (!hasNestedItems) {
return listElement
}
return (
<Accordion.Root keepMounted multiple>
{listElement}
</Accordion.Root>
)
}
@@ -0,0 +1,192 @@
import { Accordion } from "@base-ui/react/accordion"
import * as React from "react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
import { cn } from "@workspace/ui/lib/utils"
import { SidebarFlyoutMenuItems } from "./flyout-navigation"
import { resolveNavigationItemIcon } from "./item-icon"
import {
NavigationBadge,
NavigationButton,
NavigationDisclosureIcon,
NavigationLink,
type NavigationButtonProps,
} from "./navigation-button"
import { InlineNestedNavigationList } from "./nested-navigation"
import type { GetNavigationRouteState } from "./route-state"
import type { NavigationGroup, NavigationItem } from "./types"
interface PrimaryNavigationItemProps {
item: NavigationItem
getRouteState: GetNavigationRouteState
isCompact: boolean
showIconByDefault: boolean
}
function PrimaryNavigationItem({
item,
getRouteState,
isCompact,
showIconByDefault,
}: PrimaryNavigationItemProps) {
const [isMenuOpen, setMenuOpen] = React.useState(false)
const routeState = getRouteState(item.to)
React.useEffect(() => {
if (!isCompact) {
setMenuOpen(false)
}
}, [isCompact])
const navigationButtonProps: NavigationButtonProps = {
label: item.label,
layout: "responsive",
icon: resolveNavigationItemIcon(item, showIconByDefault),
...routeState,
className: cn(
"group h-11 w-full ps-3 pe-2",
"tablet:p-0 not-mobile:collapsed:p-0",
"tablet:h-16 not-mobile:collapsed:h-16"
),
}
if (!item.items?.length) {
return (
<NavigationButton
{...navigationButtonProps}
render={item.to ? <NavigationLink to={item.to} /> : undefined}
trailing={
<NavigationBadge
className="mx-2 tablet:hidden not-mobile:collapsed:hidden"
itemId={item.id}
/>
}
/>
)
}
const navigationTrigger = (
<NavigationButton
{...navigationButtonProps}
trailing={
<>
<NavigationBadge
itemId={item.id}
className="mx-2 tablet:hidden not-mobile:collapsed:hidden"
/>
<NavigationDisclosureIcon staticWhenCompact />
</>
}
/>
)
if (isCompact) {
return (
<DropdownMenu
open={isMenuOpen}
onOpenChange={(open, eventDetails) => {
const event = eventDetails.event
const isPointerEvent =
typeof PointerEvent !== "undefined" && event instanceof PointerEvent
const isMousePress =
eventDetails.reason === "trigger-press" &&
(isPointerEvent
? event.pointerType === "mouse"
: event instanceof MouseEvent)
if (isMousePress) {
eventDetails.cancel()
return
}
setMenuOpen(open)
}}
>
<DropdownMenuTrigger
render={navigationTrigger}
openOnHover
delay={100}
closeDelay={100}
/>
<DropdownMenuContent
side="inline-end"
align="start"
sideOffset={8}
className="w-fit max-w-(--available-width) min-w-44"
showArrow
>
<div className="grid gap-0.5">
<SidebarFlyoutMenuItems
items={item.items}
getRouteState={getRouteState}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger render={navigationTrigger} />
</Accordion.Header>
<Accordion.Panel className="h-(--accordion-panel-height) overflow-hidden transition-[height] duration-150 ease-[ease-out] data-ending-style:h-0 data-starting-style:h-0">
<InlineNestedNavigationList
className="pb-1 pl-6"
items={item.items}
getRouteState={getRouteState}
/>
</Accordion.Panel>
</Accordion.Item>
)
}
interface PrimaryNavigationProps {
className?: string
items: readonly NavigationGroup[]
getRouteState: GetNavigationRouteState
isCompact?: boolean
}
export function PrimaryNavigation({
items,
className,
getRouteState,
isCompact = false,
}: PrimaryNavigationProps) {
return (
<Accordion.Root
className={cn(
"w-74 space-y-7 p-4 tablet:w-22 not-mobile:collapsed:w-22",
"tablet:space-y-1 tablet:p-2 not-mobile:collapsed:space-y-1 not-mobile:collapsed:p-2",
className
)}
keepMounted
multiple
>
{items.map((group) => (
<div key={group.id}>
<div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden">
{group.label}
</div>
<div className="tablet:space-y-1 not-mobile:collapsed:space-y-1">
{group.items.map((item) => (
<PrimaryNavigationItem
key={item.id}
item={item}
getRouteState={getRouteState}
isCompact={isCompact}
showIconByDefault={group.showIcon ?? true}
/>
))}
</div>
</div>
))}
</Accordion.Root>
)
}
@@ -0,0 +1,22 @@
export interface NavigationRouteState {
isActive: boolean
isCurrent: boolean
}
export type GetNavigationRouteState = (
to: string | undefined
) => NavigationRouteState
export function resolveNavigationRouteState(
pathname: string,
to: string | undefined
): NavigationRouteState {
if (!to) {
return { isActive: false, isCurrent: false }
}
const isCurrent = pathname === to
const isActive = isCurrent || (to !== "/" && pathname.startsWith(`${to}/`))
return { isActive, isCurrent }
}
@@ -0,0 +1,17 @@
import type { IconData } from "../../components/icon"
export interface NavigationItem {
id: string
to?: string
label: string
icon?: IconData
showIcon?: boolean
items?: readonly NavigationItem[]
}
export interface NavigationGroup {
id: string
label: string
showIcon?: boolean
items: readonly NavigationItem[]
}
@@ -0,0 +1,146 @@
import { useLocation } from "@tanstack/react-router"
import * as React from "react"
import type { IconData } from "../../components/icon"
import { resolveNavigationItemIcon } from "./item-icon"
import type { NavigationGroup, NavigationItem } from "./types"
export type NavigationKey = NavigationItem["id"]
export interface NavigationInfo {
id: NavigationKey
icon?: IconData
items?: readonly NavigationInfo[]
label: string
to?: string
}
interface NavigationRecord {
info: NavigationInfo
stack: readonly NavigationInfo[]
}
interface NavigationIndex {
recordsByKey: ReadonlyMap<NavigationKey, NavigationRecord>
recordsByPathname: ReadonlyMap<string, NavigationRecord>
}
const EMPTY_NAVIGATION_STACK: readonly NavigationInfo[] = []
const NavigationContext = React.createContext<NavigationIndex | null>(null)
function normalizePathname(pathname: string) {
if (pathname === "/") {
return pathname
}
return pathname.replace(/\/+$/, "")
}
function createNavigationIndex(
groups: readonly NavigationGroup[]
): NavigationIndex {
const recordsByKey = new Map<NavigationKey, NavigationRecord>()
const recordsByPathname = new Map<string, NavigationRecord>()
const createInfoItems = (
items: readonly NavigationItem[],
showIconByDefault: boolean
): readonly NavigationInfo[] =>
items.map((item) => ({
id: item.id,
icon: resolveNavigationItemIcon(item, showIconByDefault),
items: item.items?.length
? createInfoItems(item.items, false)
: undefined,
label: item.label,
to: item.to,
}))
const indexInfoItems = (
items: readonly NavigationInfo[],
parentStack: readonly NavigationInfo[]
) => {
for (const info of items) {
const stack = [...parentStack, info]
const record = { info, stack } satisfies NavigationRecord
recordsByKey.set(info.id, record)
if (info.to) {
recordsByPathname.set(normalizePathname(info.to), record)
}
if (info.items?.length) {
indexInfoItems(info.items, stack)
}
}
}
for (const group of groups) {
const items = createInfoItems(group.items, group.showIcon ?? true)
indexInfoItems(items, EMPTY_NAVIGATION_STACK)
}
return { recordsByKey, recordsByPathname }
}
export interface NavigationProviderProps {
children: React.ReactNode
groups: readonly NavigationGroup[]
}
export function NavigationProvider({
children,
groups,
}: NavigationProviderProps) {
const index = React.useMemo(() => createNavigationIndex(groups), [groups])
return (
<NavigationContext.Provider value={index}>
{children}
</NavigationContext.Provider>
)
}
function useNavigationRecord(key?: NavigationKey) {
const index = React.useContext(NavigationContext)
const pathname = useLocation({ select: (location) => location.pathname })
if (!index) {
throw new Error(
"useNavigationInfo and useNavigationStack must be used within NavigationProvider."
)
}
if (key !== undefined) {
return index.recordsByKey.get(key)
}
return index.recordsByPathname.get(normalizePathname(pathname))
}
/**
* Returns one navigation node.
*
* Pass a navigation item `id` to select it explicitly. Without an `id`, the
* item matching the current route is returned.
*/
export function useNavigationInfo(
key?: NavigationKey
): NavigationInfo | undefined {
return useNavigationRecord(key)?.info
}
/**
* Returns the navigation hierarchy from its top-level item to the selected
* node.
*
* Pass a navigation item `id` to select it explicitly. Without an `id`, the
* hierarchy matching the current route is returned.
*/
export function useNavigationStack(
key?: NavigationKey
): readonly NavigationInfo[] {
return useNavigationRecord(key)?.stack ?? EMPTY_NAVIGATION_STACK
}
@@ -0,0 +1,31 @@
export {
NotificationCenter,
type NotificationCenterProps,
type NotificationCenterStatus,
} from "./notification-center"
export {
NotificationItem,
type NotificationItemProps,
} from "./notification-item"
export {
NotificationCenterSheet,
type NotificationCenterSheetProps,
NotificationCenterTrigger,
type NotificationCenterTriggerProps,
} from "./notification-sheet"
export {
useNotifications,
type UseNotificationsOptions,
type UseNotificationsResult,
} from "./use-notifications"
export type {
Notification,
NotificationAction,
NotificationActionEvent,
NotificationActionHandler,
NotificationActionIntent,
NotificationAvatarMedia,
NotificationIconMedia,
NotificationMedia,
NotificationTone,
} from "./types"
@@ -0,0 +1,145 @@
import * as React from "react"
import { CheckCheckIcon } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyTitle,
} from "@workspace/ui/components/empty"
import { ItemGroup } from "@workspace/ui/components/item"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { cn } from "@workspace/ui/lib/utils"
import { NotificationItem } from "./notification-item"
import type { Notification, NotificationActionHandler } from "./types"
export type NotificationCenterStatus = "error" | "pending" | "success"
export interface NotificationCenterProps extends Omit<
React.ComponentProps<"section">,
"title"
> {
description?: React.ReactNode
notifications: readonly Notification[]
onAction?: NotificationActionHandler
onMarkAllAsRead: VoidFunction
onRefetch: VoidFunction
onViewAll?: VoidFunction
status: NotificationCenterStatus
title?: React.ReactNode
viewAllLabel?: React.ReactNode
}
export function NotificationCenter({
className,
description = "查看并处理通知",
notifications,
onAction,
onMarkAllAsRead,
onRefetch,
onViewAll,
status,
title = "Notifications",
viewAllLabel = "查看全部",
...props
}: NotificationCenterProps) {
const unreadCount = notifications.filter(
(notification) => !notification.isRead
).length
return (
<section
className={cn("flex min-h-0 flex-1 flex-col gap-4", className)}
{...props}
>
<header className="grid grid-cols-[1fr_auto] p-4">
<h2 className="order-1 font-heading font-medium text-foreground">
{title}
{unreadCount > 0 && (
<span className="ms-1.5 text-sm font-normal text-muted-foreground">
({unreadCount})
</span>
)}
</h2>
{description && <p className="sr-only">{description}</p>}
<Tooltip>
<TooltipTrigger
delay={0}
className="order-2"
disabled={unreadCount === 0}
render={
<Button
aria-label="全部标记为已读"
variant="ghost"
size="icon-sm"
/>
}
onClick={onMarkAllAsRead}
>
<CheckCheckIcon />
</TooltipTrigger>
<TooltipContent align="end" showArrow>
</TooltipContent>
</Tooltip>
</header>
{status === "pending" ? (
<div
role="status"
className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground"
>
</div>
) : status === "error" ? (
<Empty>
<EmptyContent>
<EmptyTitle></EmptyTitle>
<EmptyDescription></EmptyDescription>
<Button variant="outline" onClick={onRefetch}>
</Button>
</EmptyContent>
</Empty>
) : notifications.length === 0 ? (
<Empty>
<EmptyContent>
<EmptyTitle></EmptyTitle>
<EmptyDescription></EmptyDescription>
</EmptyContent>
</Empty>
) : (
<ItemGroup
role="list"
className="no-scrollbar min-h-0 flex-1 scroll-fade gap-0! overflow-y-auto"
>
{notifications.map((notification, index) => (
<React.Fragment key={notification.id}>
{index > 0 && (
<div
aria-hidden="true"
className="h-0 border-t border-dashed border-border dark:border-input/40"
/>
)}
<NotificationItem
notification={notification}
onAction={onAction}
/>
</React.Fragment>
))}
</ItemGroup>
)}
<footer className="mt-auto flex flex-col gap-2 p-4">
<Button size="lg" variant="ghost" className="h-12" onClick={onViewAll}>
{viewAllLabel}
</Button>
</footer>
</section>
)
}
@@ -0,0 +1,221 @@
import * as React from "react"
import { Link } from "@tanstack/react-router"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar"
import { Button } from "@workspace/ui/components/button"
import {
Item as BaseItem,
ItemContent,
ItemDescription,
ItemFooter,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item"
import { cn } from "@workspace/ui/lib/utils"
import { Icon } from "../../components/icon"
import type {
Notification,
NotificationActionHandler,
NotificationActionIntent,
NotificationMedia,
NotificationTone,
} from "./types"
const ACTION_VARIANTS: Record<
NotificationActionIntent,
"default" | "destructive" | "outline"
> = {
destructive: "destructive",
neutral: "outline",
primary: "default",
}
const ICON_TONE_CLASSES: Record<NotificationTone, string> = {
destructive: "bg-destructive/10 text-destructive",
info: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
neutral: "bg-muted text-muted-foreground",
success: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
warning: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
}
const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat("zh-CN", {
numeric: "auto",
})
const RELATIVE_TIME_UNITS: ReadonlyArray<
readonly [seconds: number, unit: Intl.RelativeTimeFormatUnit]
> = [
[60 * 60 * 24 * 365, "year"],
[60 * 60 * 24 * 30, "month"],
[60 * 60 * 24, "day"],
[60 * 60, "hour"],
[60, "minute"],
]
function getInitials(media: Extract<NotificationMedia, { kind: "avatar" }>) {
if (media.fallback) {
return media.fallback
}
return (media.alt ?? "")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase()
}
function formatRelativeTime(createdAt: string) {
const createdAtTime = new Date(createdAt).getTime()
if (!Number.isFinite(createdAtTime)) {
return createdAt
}
const differenceInSeconds = (createdAtTime - Date.now()) / 1000
const absoluteDifference = Math.abs(differenceInSeconds)
for (const [seconds, unit] of RELATIVE_TIME_UNITS) {
if (absoluteDifference >= seconds) {
return RELATIVE_TIME_FORMAT.format(
Math.round(differenceInSeconds / seconds),
unit
)
}
}
return "刚刚"
}
function NotificationItemMedia({ media }: { media: NotificationMedia }) {
if (media.kind === "avatar") {
return (
<Avatar size="lg">
<AvatarImage src={media.src} alt={media.alt} />
<AvatarFallback>{getInitials(media)}</AvatarFallback>
</Avatar>
)
}
return (
<span
className={cn(
"inline-flex size-10 items-center justify-center rounded-full",
ICON_TONE_CLASSES[media.tone ?? "neutral"]
)}
>
<Icon data={media.icon} size="lg" />
</span>
)
}
function NotificationRouteLink({
children,
to,
}: {
children: React.ReactNode
to: string
}) {
if (to === "/") {
return <Link to="/">{children}</Link>
}
return (
<Link to="/$" params={{ _splat: to.replace(/^\/+/, "") }}>
{children}
</Link>
)
}
export interface NotificationItemProps extends Omit<
React.ComponentProps<typeof BaseItem>,
"children"
> {
notification: Notification
onAction?: NotificationActionHandler
}
export function NotificationItem({
className,
notification,
onAction,
ref,
...props
}: NotificationItemProps) {
return (
<BaseItem
ref={ref}
role="listitem"
data-read={notification.isRead}
className={cn(
"relative rounded-none px-4 py-3.5 hover:bg-muted data-[read=false]:bg-primary/[0.035]",
className
)}
{...props}
>
{notification.media && (
<ItemMedia>
<NotificationItemMedia media={notification.media} />
</ItemMedia>
)}
<ItemContent>
<ItemTitle className="line-clamp-2 [&_a:hover]:text-primary">
{notification.to ? (
<NotificationRouteLink to={notification.to}>
{notification.title}
</NotificationRouteLink>
) : (
notification.title
)}
</ItemTitle>
{notification.description && (
<p className="line-clamp-2 text-sm text-foreground/80">
{notification.description}
</p>
)}
<ItemDescription className="flex items-center gap-1">
<time dateTime={notification.createdAt}>
{formatRelativeTime(notification.createdAt)}
</time>
{notification.category && (
<>
<span
aria-hidden="true"
className="inline-block size-1 rounded-full bg-current"
/>
<span>{notification.category}</span>
</>
)}
</ItemDescription>
</ItemContent>
{!notification.isRead && (
<span
aria-hidden="true"
className="mt-2 size-2 shrink-0 self-start rounded-full bg-primary"
/>
)}
{notification.actions && notification.actions.length > 0 && (
<ItemFooter
className={cn("justify-start", notification.media && "ps-13.5")}
>
{notification.actions.map((action) => (
<Button
key={action.id}
size="xs"
variant={ACTION_VARIANTS[action.intent ?? "primary"]}
onClick={() => onAction?.({ action, notification })}
>
{action.label}
</Button>
))}
</ItemFooter>
)}
</BaseItem>
)
}
@@ -0,0 +1,82 @@
import * as React from "react"
import {
createSheetHandle,
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet"
import { NotificationCenter } from "./notification-center"
import type { Notification, NotificationActionHandler } from "./types"
import {
useNotifications,
type UseNotificationsOptions,
} from "./use-notifications"
const notificationCenterSheetHandle = createSheetHandle<void>()
export type NotificationCenterTriggerProps = Omit<
React.ComponentProps<typeof SheetTrigger>,
"handle"
>
export function NotificationCenterTrigger(
props: NotificationCenterTriggerProps
) {
return <SheetTrigger {...props} handle={notificationCenterSheetHandle} />
}
export interface NotificationCenterSheetProps extends UseNotificationsOptions {
onAction?: NotificationActionHandler
onMarkAllAsRead?: (notifications: readonly Notification[]) => void
onViewAll?: VoidFunction
title?: string
}
export function NotificationCenterSheet({
onAction,
onMarkAllAsRead,
onViewAll,
queryFn,
queryKey,
title = "Notifications",
}: NotificationCenterSheetProps) {
const notifications = useNotifications({ queryFn, queryKey })
const handleAction = React.useCallback<NotificationActionHandler>(
(event) => {
notifications.executeAction(event)
onAction?.(event)
},
[notifications, onAction]
)
const handleMarkAllAsRead = React.useCallback(() => {
const nextNotifications = notifications.markAllAsRead()
onMarkAllAsRead?.(nextNotifications)
}, [notifications, onMarkAllAsRead])
return (
<Sheet handle={notificationCenterSheetHandle}>
<SheetContent
className="w-105 gap-0 bg-popover/90"
showCloseButton={false}
>
<SheetTitle className="sr-only">{title}</SheetTitle>
<SheetDescription className="sr-only"></SheetDescription>
<NotificationCenter
notifications={notifications.notifications}
onAction={handleAction}
onMarkAllAsRead={handleMarkAllAsRead}
onRefetch={notifications.refetch}
onViewAll={onViewAll}
status={notifications.status}
title={title}
/>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,46 @@
import type { IconData } from "../../components/icon"
export type NotificationTone =
"destructive" | "info" | "neutral" | "success" | "warning"
export interface NotificationAvatarMedia {
alt?: string
fallback?: string
kind: "avatar"
src: string
}
export interface NotificationIconMedia {
icon: IconData
kind: "icon"
tone?: NotificationTone
}
export type NotificationMedia = NotificationAvatarMedia | NotificationIconMedia
export type NotificationActionIntent = "destructive" | "neutral" | "primary"
export interface NotificationAction {
id: string
intent?: NotificationActionIntent
label: string
}
export interface Notification {
actions?: readonly NotificationAction[]
category?: string
createdAt: string
description?: string
id: string
isRead: boolean
media?: NotificationMedia
title: string
to?: string
}
export interface NotificationActionEvent {
action: NotificationAction
notification: Notification
}
export type NotificationActionHandler = (event: NotificationActionEvent) => void
@@ -0,0 +1,107 @@
import * as React from "react"
import {
type QueryFunction,
type QueryKey,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import type {
Notification,
NotificationActionEvent,
NotificationActionHandler,
} from "./types"
const EMPTY_NOTIFICATIONS: readonly Notification[] = []
export interface UseNotificationsResult {
error: Error | null
executeAction: NotificationActionHandler
isFetching: boolean
markAllAsRead: () => readonly Notification[]
markAsRead: (id: string) => void
notifications: readonly Notification[]
refetch: VoidFunction
status: "error" | "pending" | "success"
unreadCount: number
}
export interface UseNotificationsOptions {
queryFn: QueryFunction<readonly Notification[]>
queryKey: QueryKey
}
export function useNotifications({
queryFn,
queryKey,
}: UseNotificationsOptions): UseNotificationsResult {
const queryClient = useQueryClient()
const query = useQuery({
queryFn,
queryKey,
})
const queryRefetch = query.refetch
const notifications = query.data ?? EMPTY_NOTIFICATIONS
const unreadCount = notifications.filter(
(notification) => !notification.isRead
).length
const updateReadState = React.useCallback(
(predicate: (notification: Notification) => boolean) => {
queryClient.setQueryData<readonly Notification[]>(
queryKey,
(currentNotifications) =>
currentNotifications?.map((notification) =>
predicate(notification)
? { ...notification, isRead: true }
: notification
) ?? EMPTY_NOTIFICATIONS
)
},
[queryClient, queryKey]
)
const markAsRead = React.useCallback(
(id: string) => {
updateReadState((notification) => notification.id === id)
},
[updateReadState]
)
const markAllAsRead = React.useCallback(() => {
const nextNotifications = notifications.map((notification) => ({
...notification,
isRead: true,
}))
queryClient.setQueryData<readonly Notification[]>(
queryKey,
nextNotifications
)
return nextNotifications
}, [notifications, queryClient, queryKey])
const executeAction = React.useCallback(
({ notification }: NotificationActionEvent) => {
markAsRead(notification.id)
},
[markAsRead]
)
const refetch = React.useCallback(() => {
void queryRefetch()
}, [queryRefetch])
return {
error: query.error,
executeAction,
isFetching: query.isFetching,
markAllAsRead,
markAsRead,
notifications,
refetch,
status: query.status,
unreadCount,
}
}
+30
View File
@@ -0,0 +1,30 @@
import { type HugeiconsIconProps, HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@workspace/ui/lib/utils"
export type IconData = HugeiconsIconProps["icon"]
export type IconProps = Omit<HugeiconsIconProps, "icon"> & {
data: IconData
size?: "xs" | "sm" | "md" | "lg" | "xl"
}
const sizeClasses = {
xs: "size-3",
sm: "size-4",
md: "size-5",
lg: "size-6",
xl: "size-7",
}
export function Icon({ data, className, size = "md", ...props }: IconProps) {
return (
<HugeiconsIcon
icon={data}
color="currentColor"
aria-hidden="true"
strokeWidth={1.75}
className={cn(sizeClasses[size], className)}
{...props}
/>
)
}
@@ -0,0 +1,70 @@
import * as React from "react"
import { getOverflowAncestors, isOverflowElement } from "@floating-ui/utils/dom"
import { addEventListener } from "@base-ui/utils/addEventListener"
import { mergeCleanups } from "@base-ui/utils/mergeCleanups"
import { ownerWindow } from "@base-ui/utils/owner"
type ScrollContainer = HTMLElement | React.RefObject<HTMLElement | null>
type ScrollTarget = Element | VisualViewport | Window
function getScrollTargets(element: HTMLElement): ScrollTarget[] {
const targets: ScrollTarget[] = []
if (isOverflowElement(element)) {
targets.push(element)
}
targets.push(...getOverflowAncestors(element, [], false))
if (targets.length === 0) {
targets.push(ownerWindow(element))
}
return Array.from(new Set(targets))
}
export function useOnScroll(listener: VoidFunction): void
export function useOnScroll(
container: ScrollContainer,
listener: VoidFunction
): void
export function useOnScroll(
containerOrListener: ScrollContainer | VoidFunction,
listener?: VoidFunction
) {
const container =
typeof containerOrListener === "function" ? null : containerOrListener
const scrollListener =
typeof containerOrListener === "function" ? containerOrListener : listener
if (!scrollListener) {
throw new Error(
"useOnScroll requires a listener when a scroll container is provided."
)
}
const onScroll = React.useEffectEvent(scrollListener)
React.useEffect(() => {
const targets =
container === null
? [window]
: "current" in container
? container.current && getScrollTargets(container.current)
: getScrollTargets(container)
if (!targets) {
return
}
const handleScroll = () => {
onScroll()
}
return mergeCleanups(
...targets.map((target) =>
addEventListener(target, "scroll", handleScroll, { passive: true })
)
)
}, [container])
}
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react"
import { useIsFetching, useQueryClient } from "@tanstack/react-query"
const ACTIVE_QUERY_FILTERS = { type: "active" } as const
export function useRefresh() {
const queryClient = useQueryClient()
const queryCache = queryClient.getQueryCache()
const isRefreshing = useIsFetching(ACTIVE_QUERY_FILTERS) > 0
const subscribe = React.useCallback(
(listener: VoidFunction) => queryCache.subscribe(listener),
[queryCache]
)
const getSnapshot = React.useCallback(
() => queryCache.findAll(ACTIVE_QUERY_FILTERS).length > 0,
[queryCache]
)
const canRefresh = React.useSyncExternalStore(
subscribe,
getSnapshot,
() => false
)
const refresh = React.useCallback(() => {
if (!canRefresh || isRefreshing) {
return
}
void queryClient.refetchQueries(ACTIVE_QUERY_FILTERS)
}, [canRefresh, isRefreshing, queryClient])
return { canRefresh, isRefreshing, refresh }
}
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { fireEvent, render } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import {
getNavigationCommandProps,
NavigationActionTarget,
navigationActions,
} from "./command-actions"
describe("navigation command", () => {
it("connects an action button to its command target", () => {
expect(getNavigationCommandProps(navigationActions.search)).toEqual({
command: "--invoke",
commandfor: "navigation-action-search",
})
})
it("invokes the matching command target", () => {
const onInvoke = vi.fn()
const { container } = render(
<NavigationActionTarget
action={navigationActions.search}
onInvoke={onInvoke}
/>
)
const target = container.querySelector("#navigation-action-search")
const event = new Event("command")
Object.defineProperty(event, "command", { value: "--invoke" })
fireEvent(target!, event)
expect(onInvoke).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,55 @@
import React from "react"
export const navigationActions = {
appearance: "appearance",
logout: "logout",
passwords: "passwords",
search: "search",
userMenu: "user-menu",
} as const
export type NavigationAction =
(typeof navigationActions)[keyof typeof navigationActions]
const NAVIGATION_INVOKE_COMMAND = "--invoke"
const NAVIGATION_ACTION_TARGET_PREFIX = "navigation-action-"
export function getNavigationCommandProps(action: NavigationAction) {
return {
command: NAVIGATION_INVOKE_COMMAND,
commandfor: `${NAVIGATION_ACTION_TARGET_PREFIX}${action}`,
}
}
export function NavigationActionTarget({
action,
onInvoke,
}: {
action: NavigationAction
onInvoke: VoidFunction
}) {
const targetRef = React.useRef<HTMLSpanElement>(null)
const invoke = React.useEffectEvent(onInvoke)
React.useEffect(() => {
const target = targetRef.current
if (!target) return
const handleCommand = (event: Event) => {
if ((event as CommandEvent).command === NAVIGATION_INVOKE_COMMAND) {
invoke()
}
}
target.addEventListener("command", handleCommand)
return () => target.removeEventListener("command", handleCommand)
}, [])
return (
<span
ref={targetRef}
id={`${NAVIGATION_ACTION_TARGET_PREFIX}${action}`}
hidden
/>
)
}
+1
View File
@@ -0,0 +1 @@
@source "../";
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/blocks/*": ["./src/*"],
"@workspace/ui/*": ["../ui/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}