mavi-dashboard 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "mavi-dashboard",
3
+ "version": "0.0.1",
4
+ "description": "React dashboard layout with sidebar, header, and theme shell",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "react",
11
+ "dashboard",
12
+ "sidebar",
13
+ "layout"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "exports": {
18
+ ".": {
19
+ "types": "./src/dashboard/index.ts",
20
+ "import": "./src/dashboard/index.ts",
21
+ "default": "./src/dashboard/index.ts"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "types": "./src/dashboard/index.ts",
26
+ "files": [
27
+ "src"
28
+ ],
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "peerDependencies": {
33
+ "@workspace/ui": "*",
34
+ "lucide-react": "^1.8.0",
35
+ "react": "^18.0.0 || ^19.0.0",
36
+ "react-dom": "^18.0.0 || ^19.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19.2.10",
40
+ "@types/react-dom": "^19.2.3",
41
+ "typescript": "^5.9.3"
42
+ }
43
+ }
@@ -0,0 +1,76 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import "@workspace/ui/globals.css"
5
+
6
+ import {
7
+ Sidebar,
8
+ SidebarContent,
9
+ SidebarFooter,
10
+ SidebarHeader,
11
+ SidebarRail,
12
+ } from "@workspace/ui/components/sidebar"
13
+ import { NavBrand } from "./nav-brand"
14
+ import { NavDefault } from "./nav-default"
15
+ import { NavMenuGroup } from "./nav-menu-group"
16
+ import type { DashboardMenuConfig, MenuGroup } from "./menu-types"
17
+
18
+ function SidebarMenuSections({
19
+ menu,
20
+ activePathname,
21
+ }: {
22
+ menu: DashboardMenuConfig
23
+ activePathname?: string
24
+ }) {
25
+ return (
26
+ <>
27
+ {menu.sections.flatMap((section, sectionIndex) =>
28
+ section.groups.map((group, groupIndex) => (
29
+ <NavMenuGroup
30
+ key={`${sectionIndex}-${groupIndex}-${group.label}`}
31
+ group={group}
32
+ activePathname={activePathname}
33
+ />
34
+ ))
35
+ )}
36
+ </>
37
+ )
38
+ }
39
+
40
+ export function AppSidebar({
41
+ brand,
42
+ menu,
43
+ footerMenuItems,
44
+ activePathname,
45
+ ...props
46
+ }: React.ComponentProps<typeof Sidebar> & {
47
+ brand: { name: string; logo: React.ReactNode; plan: string }
48
+ menu: DashboardMenuConfig
49
+ footerMenuItems: MenuGroup[]
50
+ activePathname?: string
51
+ }) {
52
+ return (
53
+ <Sidebar collapsible="icon" {...props}>
54
+ <SidebarHeader>
55
+ <NavBrand brand={brand} />
56
+ </SidebarHeader>
57
+ <SidebarContent>
58
+ <SidebarMenuSections menu={menu} activePathname={activePathname} />
59
+ </SidebarContent>
60
+ <SidebarFooter>
61
+ {footerMenuItems.length > 0 && (
62
+ <NavDefault
63
+ navMenuItems={footerMenuItems}
64
+ activePathname={activePathname}
65
+ />
66
+ )}
67
+ </SidebarFooter>
68
+ <div className="flex justify-start rounded-lg bg-accent group-data-[collapsible=icon]:hidden">
69
+ <p className="ml-4 p-1 text-[10px] text-primary">
70
+ Copyright © 2026 Mavi.
71
+ </p>
72
+ </div>
73
+ <SidebarRail />
74
+ </Sidebar>
75
+ )
76
+ }
@@ -0,0 +1,41 @@
1
+ export type BreadcrumbSegment = { label: string; href?: string }
2
+
3
+ function formatSegmentLabel(segment: string): string {
4
+ if (/^\d+$/.test(segment)) return segment
5
+ return segment
6
+ .split("-")
7
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
8
+ .join(" ")
9
+ }
10
+
11
+ export function getBreadcrumbTrail(
12
+ pathname: string | undefined
13
+ ): BreadcrumbSegment[] {
14
+ if (pathname == null || pathname === "") {
15
+ return [{ label: "Dashboard" }]
16
+ }
17
+
18
+ const p =
19
+ pathname === "/" ? "/" : pathname.replace(/\/+$/, "") || "/"
20
+
21
+ if (p === "/") {
22
+ return [{ label: "Dashboard" }]
23
+ }
24
+
25
+ const parts = p.split("/").filter(Boolean)
26
+
27
+ if (parts[0] === "project" && parts.length >= 2) {
28
+ return [{ label: "Project" }, { label: parts[parts.length - 1] }]
29
+ }
30
+
31
+ if (parts.length === 1) {
32
+ return [{ label: formatSegmentLabel(parts[0]) }]
33
+ }
34
+
35
+ return parts.map((seg, idx) => ({
36
+ label: formatSegmentLabel(seg),
37
+ ...(idx < parts.length - 1
38
+ ? { href: `/${parts.slice(0, idx + 1).join("/")}` }
39
+ : {}),
40
+ }))
41
+ }
@@ -0,0 +1,38 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { MoonIcon, SunIcon } from "lucide-react"
5
+ import { useEffect, useState } from "react"
6
+ import { Button } from "@workspace/ui/components/button"
7
+ import { useTheme } from "./theme-provider"
8
+
9
+ const ThemeToggleButton = () => {
10
+ const [mounted, setMounted] = useState(false)
11
+ const { theme, setTheme } = useTheme()
12
+
13
+ const toggleTheme = () => {
14
+ setTheme(theme === "dark" ? "light" : "dark")
15
+ }
16
+
17
+ useEffect(() => {
18
+ setMounted(true)
19
+ }, [])
20
+
21
+ // Prevent SSR flicker and hydration mismatch
22
+ if (!mounted) {
23
+ return <Button variant="ghost" className="rounded-full" size="icon" />
24
+ }
25
+
26
+ return (
27
+ <Button
28
+ variant="ghost"
29
+ className="rounded-full"
30
+ onClick={toggleTheme}
31
+ size="icon"
32
+ >
33
+ {theme === "dark" ? <SunIcon /> : <MoonIcon />}
34
+ </Button>
35
+ )
36
+ }
37
+
38
+ export default ThemeToggleButton
@@ -0,0 +1,54 @@
1
+ import * as React from "react"
2
+ import {
3
+ SidebarInset,
4
+ SidebarProvider,
5
+ } from "@workspace/ui/components/sidebar"
6
+ import { TooltipProvider } from "@workspace/ui/components/tooltip"
7
+ import { AppSidebar } from "./app-sidebar"
8
+ import type { DashboardSideConfig } from "./menu-types"
9
+ import { NavHeader } from "./nav-header"
10
+ import { ThemeProvider } from "./theme-provider"
11
+
12
+ export type {
13
+ DashboardMenuConfig,
14
+ DashboardSideConfig,
15
+ MenuGroup,
16
+ NavGroup,
17
+ NavItem,
18
+ NavItemCollaps,
19
+ NavItemDefault,
20
+ NavSection,
21
+ } from "./menu-types"
22
+
23
+ export function DashboardLayout({
24
+ children,
25
+ user,
26
+ side,
27
+ activePathname,
28
+ }: {
29
+ children: React.ReactNode
30
+ user: { name: string; email: string; avatar: string }
31
+ side: DashboardSideConfig
32
+ activePathname?: string
33
+ }) {
34
+ return (
35
+ <TooltipProvider>
36
+ <ThemeProvider>
37
+ <SidebarProvider>
38
+ <AppSidebar
39
+ brand={side.brand}
40
+ menu={side.menu}
41
+ footerMenuItems={side.footerMenuItems}
42
+ activePathname={activePathname}
43
+ />
44
+ <SidebarInset>
45
+ <NavHeader user={user} activePathname={activePathname} />
46
+ <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
47
+ {children}
48
+ </div>
49
+ </SidebarInset>
50
+ </SidebarProvider>
51
+ </ThemeProvider>
52
+ </TooltipProvider>
53
+ )
54
+ }
@@ -0,0 +1,12 @@
1
+ export * from "./breadcrumb-trail"
2
+ export { default as ThemeToggleButton } from "./button-theme"
3
+ export { DashboardLayout } from "./dashboard-layout"
4
+ export * from "./menu-types"
5
+ export * from "./nav-active"
6
+ export { AppSidebar } from "./app-sidebar"
7
+ export { NavBrand } from "./nav-brand"
8
+ export { NavDefault } from "./nav-default"
9
+ export { BreadcrumbHeader, NavHeader } from "./nav-header"
10
+ export { NavMenuGroup } from "./nav-menu-group"
11
+ export { NavUser } from "./nav-user"
12
+ export { ThemeProvider, useTheme } from "./theme-provider"
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ export type NavItemDefault = {
4
+ variant: "default"
5
+ title: string
6
+ url: string
7
+ icon: ReactNode
8
+ }
9
+
10
+ export type NavItemCollaps = {
11
+ variant: "collaps"
12
+ title: string
13
+ icon: ReactNode
14
+ items: { title: string; url: string }[]
15
+ }
16
+
17
+ export type NavItem = NavItemDefault | NavItemCollaps
18
+
19
+ export type NavGroup = {
20
+ label: string
21
+ items: NavItem[]
22
+ }
23
+
24
+ export type NavSection = {
25
+ groups: NavGroup[]
26
+ }
27
+
28
+ export type DashboardMenuConfig = {
29
+ sections: NavSection[]
30
+ }
31
+
32
+ export type MenuGroup = {
33
+ label: string
34
+ items: {
35
+ title: string
36
+ url: string
37
+ icon: ReactNode
38
+ }[]
39
+ }
40
+
41
+ export type DashboardSideConfig = {
42
+ brand: { name: string; logo: ReactNode; plan: string }
43
+ menu: DashboardMenuConfig
44
+ footerMenuItems: MenuGroup[]
45
+ }
@@ -0,0 +1,16 @@
1
+ export function isSidebarNavActive(
2
+ pathname: string | undefined,
3
+ href: string
4
+ ): boolean {
5
+ if (pathname == null || pathname === "") return false
6
+ if (!href || href === "#" || href.startsWith("#")) return false
7
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return false
8
+
9
+ return normalizePath(pathname) === normalizePath(href)
10
+ }
11
+
12
+ function normalizePath(path: string): string {
13
+ if (path === "/" || path === "") return "/"
14
+ const trimmed = path.replace(/\/+$/, "")
15
+ return trimmed === "" ? "/" : trimmed
16
+ }
@@ -0,0 +1,38 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import {
6
+ SidebarMenu,
7
+ SidebarMenuButton,
8
+ SidebarMenuItem,
9
+ } from "@workspace/ui/components/sidebar"
10
+
11
+ export function NavBrand({
12
+ brand,
13
+ }: {
14
+ brand: {
15
+ logo: React.ReactNode
16
+ name: string
17
+ plan: string
18
+ }
19
+ }) {
20
+ return (
21
+ <SidebarMenu>
22
+ <SidebarMenuItem>
23
+ <SidebarMenuButton
24
+ size="lg"
25
+ className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
26
+ >
27
+ <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
28
+ {brand.logo}
29
+ </div>
30
+ <div className="grid flex-1 text-left text-sm leading-tight">
31
+ <span className="truncate font-medium">{brand.name}</span>
32
+ <span className="truncate text-xs">{brand.plan}</span>
33
+ </div>
34
+ </SidebarMenuButton>
35
+ </SidebarMenuItem>
36
+ </SidebarMenu>
37
+ )
38
+ }
@@ -0,0 +1,52 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ SidebarGroup,
6
+ SidebarGroupLabel,
7
+ SidebarMenu,
8
+ SidebarMenuButton,
9
+ SidebarMenuItem,
10
+ } from "@workspace/ui/components/sidebar"
11
+ import { isSidebarNavActive } from "./nav-active"
12
+
13
+ export function NavDefault({
14
+ navMenuItems,
15
+ activePathname,
16
+ }: {
17
+ activePathname?: string
18
+ navMenuItems: Array<{
19
+ label: string
20
+ items: {
21
+ title: string
22
+ url: string
23
+ icon: React.ReactNode
24
+ }[]
25
+ }>
26
+ }) {
27
+ return (
28
+ <>
29
+ {navMenuItems.map((group, index) => (
30
+ <SidebarGroup key={`${group.label}-${index}`} className="">
31
+ <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
32
+ <SidebarMenu>
33
+ {group.items.map((item, itemIndex) => (
34
+ <SidebarMenuItem key={`${item.title}-${itemIndex}`}>
35
+ <SidebarMenuButton
36
+ asChild
37
+ tooltip={item.title}
38
+ isActive={isSidebarNavActive(activePathname, item.url)}
39
+ >
40
+ <a href={item.url}>
41
+ {item.icon}
42
+ <span>{item.title}</span>
43
+ </a>
44
+ </SidebarMenuButton>
45
+ </SidebarMenuItem>
46
+ ))}
47
+ </SidebarMenu>
48
+ </SidebarGroup>
49
+ ))}
50
+ </>
51
+ )
52
+ }
@@ -0,0 +1,64 @@
1
+ import * as React from "react"
2
+ import {
3
+ Breadcrumb,
4
+ BreadcrumbItem,
5
+ BreadcrumbLink,
6
+ BreadcrumbList,
7
+ BreadcrumbPage,
8
+ BreadcrumbSeparator,
9
+ } from "@workspace/ui/components/breadcrumb"
10
+ import { Separator } from "@workspace/ui/components/separator"
11
+ import { SidebarTrigger } from "@workspace/ui/components/sidebar"
12
+ import { getBreadcrumbTrail } from "./breadcrumb-trail"
13
+ import { NavUser } from "./nav-user"
14
+ import ThemeToggleButton from "./button-theme"
15
+
16
+ export function NavHeader({
17
+ user,
18
+ activePathname,
19
+ }: {
20
+ user: { name: string; email: string; avatar: string }
21
+ activePathname?: string
22
+ }) {
23
+ return (
24
+ <header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background px-4">
25
+ <div className="flex min-w-0 flex-1 items-center gap-1.5">
26
+ <SidebarTrigger className="-ml-1 shrink-0" />
27
+ <Separator
28
+ orientation="vertical"
29
+ className="mx-2 shrink-0 data-[orientation=vertical]:h-10"
30
+ />
31
+ <BreadcrumbHeader pathname={activePathname} />
32
+ </div>
33
+ <div className="ml-auto flex items-center space-x-4">
34
+ <ThemeToggleButton />
35
+ <NavUser user={user} />
36
+ </div>
37
+ </header>
38
+ )
39
+ }
40
+
41
+ export function BreadcrumbHeader({ pathname }: { pathname?: string }) {
42
+ const trail = getBreadcrumbTrail(pathname)
43
+
44
+ return (
45
+ <Breadcrumb>
46
+ <BreadcrumbList>
47
+ {trail.map((seg, i) => (
48
+ <React.Fragment key={`${seg.label}-${i}`}>
49
+ {i > 0 ? <BreadcrumbSeparator /> : null}
50
+ <BreadcrumbItem>
51
+ {i === trail.length - 1 ? (
52
+ <BreadcrumbPage>{seg.label}</BreadcrumbPage>
53
+ ) : seg.href ? (
54
+ <BreadcrumbLink href={seg.href}>{seg.label}</BreadcrumbLink>
55
+ ) : (
56
+ <span className="text-muted-foreground">{seg.label}</span>
57
+ )}
58
+ </BreadcrumbItem>
59
+ </React.Fragment>
60
+ ))}
61
+ </BreadcrumbList>
62
+ </Breadcrumb>
63
+ )
64
+ }
@@ -0,0 +1,118 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ Collapsible,
6
+ CollapsibleContent,
7
+ CollapsibleTrigger,
8
+ } from "@workspace/ui/components/collapsible"
9
+ import {
10
+ SidebarGroup,
11
+ SidebarGroupLabel,
12
+ SidebarMenu,
13
+ SidebarMenuButton,
14
+ SidebarMenuItem,
15
+ SidebarMenuSub,
16
+ SidebarMenuSubButton,
17
+ SidebarMenuSubItem,
18
+ } from "@workspace/ui/components/sidebar"
19
+ import type { NavGroup } from "./menu-types"
20
+ import { isSidebarNavActive } from "./nav-active"
21
+ import { ChevronRightIcon } from "lucide-react"
22
+
23
+ function CollapsibleNavItem({
24
+ item,
25
+ activePathname,
26
+ }: {
27
+ item: Extract<NavGroup["items"][number], { variant: "collaps" }>
28
+ activePathname?: string
29
+ }) {
30
+ const childActive =
31
+ activePathname != null &&
32
+ item.items.some((sub) => isSidebarNavActive(activePathname, sub.url))
33
+
34
+ const [open, setOpen] = React.useState(childActive)
35
+ React.useEffect(() => {
36
+ setOpen(childActive)
37
+ }, [childActive])
38
+
39
+ return (
40
+ <Collapsible
41
+ asChild
42
+ className="group/collapsible"
43
+ open={open}
44
+ onOpenChange={setOpen}
45
+ >
46
+ <SidebarMenuItem>
47
+ <CollapsibleTrigger asChild>
48
+ <SidebarMenuButton
49
+ tooltip={item.title}
50
+ // isActive={childActive}
51
+ >
52
+ {item.icon}
53
+ <span>{item.title}</span>
54
+ <ChevronRightIcon className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
55
+ </SidebarMenuButton>
56
+ </CollapsibleTrigger>
57
+ <CollapsibleContent>
58
+ <SidebarMenuSub>
59
+ {item.items.map((sub, subIndex) => (
60
+ <SidebarMenuSubItem key={`${sub.title}-${subIndex}`}>
61
+ <SidebarMenuSubButton
62
+ asChild
63
+ isActive={isSidebarNavActive(activePathname, sub.url)}
64
+ >
65
+ <a href={sub.url}>
66
+ <span>{sub.title}</span>
67
+ </a>
68
+ </SidebarMenuSubButton>
69
+ </SidebarMenuSubItem>
70
+ ))}
71
+ </SidebarMenuSub>
72
+ </CollapsibleContent>
73
+ </SidebarMenuItem>
74
+ </Collapsible>
75
+ )
76
+ }
77
+
78
+ export function NavMenuGroup({
79
+ group,
80
+ activePathname,
81
+ }: {
82
+ group: NavGroup
83
+ activePathname?: string
84
+ }) {
85
+ return (
86
+ <SidebarGroup>
87
+ <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
88
+ <SidebarMenu>
89
+ {group.items.map((item, itemIndex) => {
90
+ const rowKey = `${group.label}-${item.title}-${itemIndex}`
91
+ if (item.variant === "default") {
92
+ return (
93
+ <SidebarMenuItem key={rowKey}>
94
+ <SidebarMenuButton
95
+ asChild
96
+ tooltip={item.title}
97
+ isActive={isSidebarNavActive(activePathname, item.url)}
98
+ >
99
+ <a href={item.url}>
100
+ {item.icon}
101
+ <span>{item.title}</span>
102
+ </a>
103
+ </SidebarMenuButton>
104
+ </SidebarMenuItem>
105
+ )
106
+ }
107
+ return (
108
+ <CollapsibleNavItem
109
+ key={rowKey}
110
+ item={item}
111
+ activePathname={activePathname}
112
+ />
113
+ )
114
+ })}
115
+ </SidebarMenu>
116
+ </SidebarGroup>
117
+ )
118
+ }
@@ -0,0 +1,40 @@
1
+ import * as React from "react"
2
+ import { LogOut, Settings, User } from "lucide-react"
3
+ import { Avatar, AvatarImage } from "@workspace/ui/components/avatar"
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuLabel,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger,
11
+ } from "@workspace/ui/components/dropdown-menu"
12
+
13
+ export function NavUser({
14
+ user,
15
+ }: {
16
+ user: { name: string; email: string; avatar: string }
17
+ }) {
18
+ return (
19
+ <DropdownMenu>
20
+ <DropdownMenuTrigger className="rounded-full focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-hidden">
21
+ <Avatar>
22
+ <AvatarImage src={user.avatar} />
23
+ </Avatar>
24
+ </DropdownMenuTrigger>
25
+ <DropdownMenuContent align="end">
26
+ <DropdownMenuLabel>{user.name}</DropdownMenuLabel>
27
+ <DropdownMenuSeparator />
28
+ <DropdownMenuItem>
29
+ <User className="h-4 w-4" /> Profile
30
+ </DropdownMenuItem>
31
+ <DropdownMenuItem>
32
+ <Settings className="h-4 w-4" /> Settings
33
+ </DropdownMenuItem>
34
+ <DropdownMenuItem variant="destructive">
35
+ <LogOut className="h-4 w-4" /> Logout
36
+ </DropdownMenuItem>
37
+ </DropdownMenuContent>
38
+ </DropdownMenu>
39
+ )
40
+ }
@@ -0,0 +1,230 @@
1
+ /* eslint-disable react-refresh/only-export-components */
2
+ import * as React from "react"
3
+
4
+ type Theme = "dark" | "light" | "system"
5
+ type ResolvedTheme = "dark" | "light"
6
+
7
+ type ThemeProviderProps = {
8
+ children: React.ReactNode
9
+ defaultTheme?: Theme
10
+ storageKey?: string
11
+ disableTransitionOnChange?: boolean
12
+ }
13
+
14
+ type ThemeProviderState = {
15
+ theme: Theme
16
+ setTheme: (theme: Theme) => void
17
+ }
18
+
19
+ const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
20
+ const THEME_VALUES: Theme[] = ["dark", "light", "system"]
21
+
22
+ const ThemeProviderContext = React.createContext<
23
+ ThemeProviderState | undefined
24
+ >(undefined)
25
+
26
+ function isTheme(value: string | null): value is Theme {
27
+ if (value === null) {
28
+ return false
29
+ }
30
+
31
+ return THEME_VALUES.includes(value as Theme)
32
+ }
33
+
34
+ function getSystemTheme(): ResolvedTheme {
35
+ if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
36
+ return "dark"
37
+ }
38
+
39
+ return "light"
40
+ }
41
+
42
+ function disableTransitionsTemporarily() {
43
+ const style = document.createElement("style")
44
+ style.appendChild(
45
+ document.createTextNode(
46
+ "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
47
+ )
48
+ )
49
+ document.head.appendChild(style)
50
+
51
+ return () => {
52
+ window.getComputedStyle(document.body)
53
+ requestAnimationFrame(() => {
54
+ requestAnimationFrame(() => {
55
+ style.remove()
56
+ })
57
+ })
58
+ }
59
+ }
60
+
61
+ function isEditableTarget(target: EventTarget | null) {
62
+ if (!(target instanceof HTMLElement)) {
63
+ return false
64
+ }
65
+
66
+ if (target.isContentEditable) {
67
+ return true
68
+ }
69
+
70
+ const editableParent = target.closest(
71
+ "input, textarea, select, [contenteditable='true']"
72
+ )
73
+ if (editableParent) {
74
+ return true
75
+ }
76
+
77
+ return false
78
+ }
79
+
80
+ export function ThemeProvider({
81
+ children,
82
+ defaultTheme = "system",
83
+ storageKey = "theme",
84
+ disableTransitionOnChange = true,
85
+ ...props
86
+ }: ThemeProviderProps) {
87
+ const [theme, setThemeState] = React.useState<Theme>(() => {
88
+ const storedTheme = localStorage.getItem(storageKey)
89
+ if (isTheme(storedTheme)) {
90
+ return storedTheme
91
+ }
92
+
93
+ return defaultTheme
94
+ })
95
+
96
+ const setTheme = React.useCallback(
97
+ (nextTheme: Theme) => {
98
+ localStorage.setItem(storageKey, nextTheme)
99
+ setThemeState(nextTheme)
100
+ },
101
+ [storageKey]
102
+ )
103
+
104
+ const applyTheme = React.useCallback(
105
+ (nextTheme: Theme) => {
106
+ const root = document.documentElement
107
+ const resolvedTheme =
108
+ nextTheme === "system" ? getSystemTheme() : nextTheme
109
+ const restoreTransitions = disableTransitionOnChange
110
+ ? disableTransitionsTemporarily()
111
+ : null
112
+
113
+ root.classList.remove("light", "dark")
114
+ root.classList.add(resolvedTheme)
115
+
116
+ if (restoreTransitions) {
117
+ restoreTransitions()
118
+ }
119
+ },
120
+ [disableTransitionOnChange]
121
+ )
122
+
123
+ React.useEffect(() => {
124
+ applyTheme(theme)
125
+
126
+ if (theme !== "system") {
127
+ return undefined
128
+ }
129
+
130
+ const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
131
+ const handleChange = () => {
132
+ applyTheme("system")
133
+ }
134
+
135
+ mediaQuery.addEventListener("change", handleChange)
136
+
137
+ return () => {
138
+ mediaQuery.removeEventListener("change", handleChange)
139
+ }
140
+ }, [theme, applyTheme])
141
+
142
+ React.useEffect(() => {
143
+ const handleKeyDown = (event: KeyboardEvent) => {
144
+ if (event.repeat) {
145
+ return
146
+ }
147
+
148
+ if (event.metaKey || event.ctrlKey || event.altKey) {
149
+ return
150
+ }
151
+
152
+ if (isEditableTarget(event.target)) {
153
+ return
154
+ }
155
+
156
+ if (event.key.toLowerCase() !== "d") {
157
+ return
158
+ }
159
+
160
+ setThemeState((currentTheme) => {
161
+ const nextTheme =
162
+ currentTheme === "dark"
163
+ ? "light"
164
+ : currentTheme === "light"
165
+ ? "dark"
166
+ : getSystemTheme() === "dark"
167
+ ? "light"
168
+ : "dark"
169
+
170
+ localStorage.setItem(storageKey, nextTheme)
171
+ return nextTheme
172
+ })
173
+ }
174
+
175
+ window.addEventListener("keydown", handleKeyDown)
176
+
177
+ return () => {
178
+ window.removeEventListener("keydown", handleKeyDown)
179
+ }
180
+ }, [storageKey])
181
+
182
+ React.useEffect(() => {
183
+ const handleStorageChange = (event: StorageEvent) => {
184
+ if (event.storageArea !== localStorage) {
185
+ return
186
+ }
187
+
188
+ if (event.key !== storageKey) {
189
+ return
190
+ }
191
+
192
+ if (isTheme(event.newValue)) {
193
+ setThemeState(event.newValue)
194
+ return
195
+ }
196
+
197
+ setThemeState(defaultTheme)
198
+ }
199
+
200
+ window.addEventListener("storage", handleStorageChange)
201
+
202
+ return () => {
203
+ window.removeEventListener("storage", handleStorageChange)
204
+ }
205
+ }, [defaultTheme, storageKey])
206
+
207
+ const value = React.useMemo(
208
+ () => ({
209
+ theme,
210
+ setTheme,
211
+ }),
212
+ [theme, setTheme]
213
+ )
214
+
215
+ return (
216
+ <ThemeProviderContext.Provider {...props} value={value}>
217
+ {children}
218
+ </ThemeProviderContext.Provider>
219
+ )
220
+ }
221
+
222
+ export const useTheme = () => {
223
+ const context = React.useContext(ThemeProviderContext)
224
+
225
+ if (context === undefined) {
226
+ throw new Error("useTheme must be used within a ThemeProvider")
227
+ }
228
+
229
+ return context
230
+ }