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:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@source "../";
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user