committed by
GitHub
28 changed files with 3158 additions and 599 deletions
@ -1 +0,0 @@ |
|||
Subproject commit a1b8c3d171445b2eebfd4b5bd1e4876f3bbed605 |
|||
@ -0,0 +1,22 @@ |
|||
{ |
|||
"$schema": "https://ui.shadcn.com/schema.json", |
|||
"style": "new-york", |
|||
"rsc": false, |
|||
"tsx": true, |
|||
"tailwind": { |
|||
"config": "", |
|||
"css": "src/base.css", |
|||
"baseColor": "slate", |
|||
"cssVariables": true, |
|||
"prefix": "" |
|||
}, |
|||
"iconLibrary": "lucide", |
|||
"aliases": { |
|||
"components": "@/components", |
|||
"utils": "@/lib/utils", |
|||
"ui": "@/components/ui", |
|||
"lib": "@/lib", |
|||
"hooks": "@/hooks" |
|||
}, |
|||
"registries": {} |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
{ |
|||
"name": "@meshtastic/ui", |
|||
"version": "0.1.0", |
|||
"license": "GPL-3.0-only", |
|||
"repository": { |
|||
"type": "git", |
|||
"url": "git+https://github.com/meshtastic/web.git" |
|||
}, |
|||
"type": "module", |
|||
"files": [ |
|||
"dist", |
|||
"!dist/**/*.test.*" |
|||
], |
|||
"sideEffects": [ |
|||
"**/*.css" |
|||
], |
|||
"exports": { |
|||
".": { |
|||
"types": "./dist/index.d.ts", |
|||
"default": "./dist/index.js" |
|||
}, |
|||
"./theme/default.css": "./dist/theme/default.css" |
|||
}, |
|||
"main": "./dist/index.js", |
|||
"module": "./dist/index.js", |
|||
"types": "./dist/index.d.ts", |
|||
"scripts": { |
|||
"start": "npm run dev", |
|||
"dev": "vite dev", |
|||
"watch": "vite build --watch", |
|||
"build": "vite build && publint", |
|||
"typecheck": "tsc -p tsconfig.json --noEmit", |
|||
"preview": "vite preview", |
|||
"lint": "eslint . --max-warnings 0", |
|||
"lint:fix": "npm run lint -- --fix", |
|||
"format": "prettier --check .", |
|||
"format:fix": "prettier --write .", |
|||
"test": "vitest" |
|||
}, |
|||
"peerDependencies": { |
|||
"@radix-ui/react-slot": ">=1.0.2", |
|||
"class-variance-authority": ">=0.7.0", |
|||
"react": ">=19", |
|||
"react-dom": ">=19", |
|||
"tailwind-merge": ">=2.5.0", |
|||
"tailwindcss": "^4.1.7" |
|||
}, |
|||
"dependencies": { |
|||
"@radix-ui/react-collapsible": "^1.1.12", |
|||
"@radix-ui/react-dialog": "^1.1.15", |
|||
"@radix-ui/react-dropdown-menu": "^2.1.16", |
|||
"@radix-ui/react-separator": "^1.1.7", |
|||
"@radix-ui/react-slot": "^1.2.3", |
|||
"@radix-ui/react-tooltip": "^1.2.8", |
|||
"@tanstack/react-router": "^1.132.47", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
"lucide-react": "^0.545.0", |
|||
"tailwind-merge": "^2.6.0" |
|||
}, |
|||
"devDependencies": { |
|||
"@tailwindcss/postcss": "^4.1.7", |
|||
"@tailwindcss/vite": "^4.1.14", |
|||
"@types/react": "^18.3.5", |
|||
"@types/react-dom": "^18.3.0", |
|||
"@vitejs/plugin-react": "^4.3.4", |
|||
"publint": "^0.3.14", |
|||
"tailwindcss": "^4.1.14", |
|||
"tw-animate-css": "^1.4.0", |
|||
"typescript": "^5.6.3", |
|||
"vite": "^7.0.0", |
|||
"vite-plugin-dts": "^4.5.4", |
|||
"vite-plugin-static-copy": "^3.1.4", |
|||
"vitest": "^3.0.0" |
|||
} |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
body.dark { |
|||
color-scheme: dark; |
|||
} |
|||
|
|||
body:not(.dark) { |
|||
color-scheme: light; |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
import { createContext, useContext, useEffect, useState } from "react" |
|||
|
|||
type Theme = "dark" | "light" | "system" |
|||
|
|||
type ThemeProviderProps = { |
|||
children: React.ReactNode |
|||
defaultTheme?: Theme |
|||
storageKey?: string |
|||
} |
|||
|
|||
type ThemeProviderState = { |
|||
theme: Theme |
|||
setTheme: (theme: Theme) => void |
|||
} |
|||
|
|||
const initialState: ThemeProviderState = { |
|||
theme: "system", |
|||
setTheme: () => null, |
|||
} |
|||
|
|||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState) |
|||
|
|||
export function ThemeProvider({ |
|||
children, |
|||
defaultTheme = "system", |
|||
storageKey = "vite-ui-theme", |
|||
...props |
|||
}: ThemeProviderProps) { |
|||
const [theme, setTheme] = useState<Theme>( |
|||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme |
|||
) |
|||
|
|||
useEffect(() => { |
|||
const root = window.document.documentElement |
|||
|
|||
root.classList.remove("light", "dark") |
|||
|
|||
if (theme === "system") { |
|||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") |
|||
.matches |
|||
? "dark" |
|||
: "light" |
|||
|
|||
root.classList.add(systemTheme) |
|||
return |
|||
} |
|||
|
|||
root.classList.add(theme) |
|||
}, [theme]) |
|||
|
|||
const value = { |
|||
theme, |
|||
setTheme: (theme: Theme) => { |
|||
localStorage.setItem(storageKey, theme) |
|||
setTheme(theme) |
|||
}, |
|||
} |
|||
|
|||
return ( |
|||
<ThemeProviderContext.Provider {...props} value={value}> |
|||
{children} |
|||
</ThemeProviderContext.Provider> |
|||
) |
|||
} |
|||
|
|||
export const useTheme = () => { |
|||
const context = useContext(ThemeProviderContext) |
|||
|
|||
// If the provider is missing, context will be initialState (setTheme is a no-op)
|
|||
if (context.setTheme === initialState.setTheme) |
|||
throw new Error("useTheme must be used within a ThemeProvider: provider is missing") |
|||
|
|||
return context |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
import * as React from "react" |
|||
import { Slot } from "@radix-ui/react-slot" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const badgeVariants = cva( |
|||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: |
|||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", |
|||
secondary: |
|||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", |
|||
destructive: |
|||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", |
|||
outline: |
|||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
function Badge({ |
|||
className, |
|||
variant, |
|||
asChild = false, |
|||
...props |
|||
}: React.ComponentProps<"span"> & |
|||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { |
|||
const Comp = asChild ? Slot : "span" |
|||
|
|||
return ( |
|||
<Comp |
|||
data-slot="badge" |
|||
className={cn(badgeVariants({ variant }), className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { Badge, badgeVariants } |
|||
@ -0,0 +1,60 @@ |
|||
import * as React from "react" |
|||
import { Slot } from "@radix-ui/react-slot" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const buttonVariants = cva( |
|||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: "bg-primary text-primary-foreground hover:bg-primary/90", |
|||
destructive: |
|||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", |
|||
outline: |
|||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", |
|||
secondary: |
|||
"bg-secondary text-secondary-foreground hover:bg-secondary/80", |
|||
ghost: |
|||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", |
|||
link: "text-primary underline-offset-4 hover:underline", |
|||
}, |
|||
size: { |
|||
default: "h-9 px-4 py-2 has-[>svg]:px-3", |
|||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", |
|||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", |
|||
icon: "size-9", |
|||
"icon-sm": "size-8", |
|||
"icon-lg": "size-10", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
size: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
function Button({ |
|||
className, |
|||
variant, |
|||
size, |
|||
asChild = false, |
|||
...props |
|||
}: React.ComponentProps<"button"> & |
|||
VariantProps<typeof buttonVariants> & { |
|||
asChild?: boolean |
|||
}) { |
|||
const Comp = asChild ? Slot : "button" |
|||
|
|||
return ( |
|||
<Comp |
|||
data-slot="button" |
|||
className={cn(buttonVariants({ variant, size, className }))} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { Button, buttonVariants } |
|||
@ -0,0 +1,31 @@ |
|||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" |
|||
|
|||
function Collapsible({ |
|||
...props |
|||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { |
|||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> |
|||
} |
|||
|
|||
function CollapsibleTrigger({ |
|||
...props |
|||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) { |
|||
return ( |
|||
<CollapsiblePrimitive.CollapsibleTrigger |
|||
data-slot="collapsible-trigger" |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function CollapsibleContent({ |
|||
...props |
|||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) { |
|||
return ( |
|||
<CollapsiblePrimitive.CollapsibleContent |
|||
data-slot="collapsible-content" |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { Collapsible, CollapsibleTrigger, CollapsibleContent } |
|||
@ -0,0 +1,255 @@ |
|||
import * as React from "react" |
|||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" |
|||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
function DropdownMenu({ |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { |
|||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> |
|||
} |
|||
|
|||
function DropdownMenuPortal({ |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuTrigger({ |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.Trigger |
|||
data-slot="dropdown-menu-trigger" |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuContent({ |
|||
className, |
|||
sideOffset = 4, |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.Portal> |
|||
<DropdownMenuPrimitive.Content |
|||
data-slot="dropdown-menu-content" |
|||
sideOffset={sideOffset} |
|||
className={cn( |
|||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
</DropdownMenuPrimitive.Portal> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuGroup({ |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuItem({ |
|||
className, |
|||
inset, |
|||
variant = "default", |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { |
|||
inset?: boolean |
|||
variant?: "default" | "destructive" |
|||
}) { |
|||
return ( |
|||
<DropdownMenuPrimitive.Item |
|||
data-slot="dropdown-menu-item" |
|||
data-inset={inset} |
|||
data-variant={variant} |
|||
className={cn( |
|||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuCheckboxItem({ |
|||
className, |
|||
children, |
|||
checked, |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.CheckboxItem |
|||
data-slot="dropdown-menu-checkbox-item" |
|||
className={cn( |
|||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|||
className |
|||
)} |
|||
checked={checked} |
|||
{...props} |
|||
> |
|||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> |
|||
<DropdownMenuPrimitive.ItemIndicator> |
|||
<CheckIcon className="size-4" /> |
|||
</DropdownMenuPrimitive.ItemIndicator> |
|||
</span> |
|||
{children} |
|||
</DropdownMenuPrimitive.CheckboxItem> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuRadioGroup({ |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.RadioGroup |
|||
data-slot="dropdown-menu-radio-group" |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuRadioItem({ |
|||
className, |
|||
children, |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.RadioItem |
|||
data-slot="dropdown-menu-radio-item" |
|||
className={cn( |
|||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> |
|||
<DropdownMenuPrimitive.ItemIndicator> |
|||
<CircleIcon className="size-2 fill-current" /> |
|||
</DropdownMenuPrimitive.ItemIndicator> |
|||
</span> |
|||
{children} |
|||
</DropdownMenuPrimitive.RadioItem> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuLabel({ |
|||
className, |
|||
inset, |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { |
|||
inset?: boolean |
|||
}) { |
|||
return ( |
|||
<DropdownMenuPrimitive.Label |
|||
data-slot="dropdown-menu-label" |
|||
data-inset={inset} |
|||
className={cn( |
|||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuSeparator({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.Separator |
|||
data-slot="dropdown-menu-separator" |
|||
className={cn("bg-border -mx-1 my-1 h-px", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuShortcut({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<"span">) { |
|||
return ( |
|||
<span |
|||
data-slot="dropdown-menu-shortcut" |
|||
className={cn( |
|||
"text-muted-foreground ml-auto text-xs tracking-widest", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuSub({ |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { |
|||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> |
|||
} |
|||
|
|||
function DropdownMenuSubTrigger({ |
|||
className, |
|||
inset, |
|||
children, |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { |
|||
inset?: boolean |
|||
}) { |
|||
return ( |
|||
<DropdownMenuPrimitive.SubTrigger |
|||
data-slot="dropdown-menu-sub-trigger" |
|||
data-inset={inset} |
|||
className={cn( |
|||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<ChevronRightIcon className="ml-auto size-4" /> |
|||
</DropdownMenuPrimitive.SubTrigger> |
|||
) |
|||
} |
|||
|
|||
function DropdownMenuSubContent({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { |
|||
return ( |
|||
<DropdownMenuPrimitive.SubContent |
|||
data-slot="dropdown-menu-sub-content" |
|||
className={cn( |
|||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { |
|||
DropdownMenu, |
|||
DropdownMenuPortal, |
|||
DropdownMenuTrigger, |
|||
DropdownMenuContent, |
|||
DropdownMenuGroup, |
|||
DropdownMenuLabel, |
|||
DropdownMenuItem, |
|||
DropdownMenuCheckboxItem, |
|||
DropdownMenuRadioGroup, |
|||
DropdownMenuRadioItem, |
|||
DropdownMenuSeparator, |
|||
DropdownMenuShortcut, |
|||
DropdownMenuSub, |
|||
DropdownMenuSubTrigger, |
|||
DropdownMenuSubContent, |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
import * as React from "react" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
function Input({ className, type, ...props }: React.ComponentProps<"input">) { |
|||
return ( |
|||
<input |
|||
type={type} |
|||
data-slot="input" |
|||
className={cn( |
|||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", |
|||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", |
|||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { Input } |
|||
@ -0,0 +1,26 @@ |
|||
import * as React from "react" |
|||
import * as SeparatorPrimitive from "@radix-ui/react-separator" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
function Separator({ |
|||
className, |
|||
orientation = "horizontal", |
|||
decorative = true, |
|||
...props |
|||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { |
|||
return ( |
|||
<SeparatorPrimitive.Root |
|||
data-slot="separator" |
|||
decorative={decorative} |
|||
orientation={orientation} |
|||
className={cn( |
|||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { Separator } |
|||
@ -0,0 +1,139 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as SheetPrimitive from "@radix-ui/react-dialog" |
|||
import { XIcon } from "lucide-react" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { |
|||
return <SheetPrimitive.Root data-slot="sheet" {...props} /> |
|||
} |
|||
|
|||
function SheetTrigger({ |
|||
...props |
|||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { |
|||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> |
|||
} |
|||
|
|||
function SheetClose({ |
|||
...props |
|||
}: React.ComponentProps<typeof SheetPrimitive.Close>) { |
|||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> |
|||
} |
|||
|
|||
function SheetPortal({ |
|||
...props |
|||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { |
|||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> |
|||
} |
|||
|
|||
function SheetOverlay({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) { |
|||
return ( |
|||
<SheetPrimitive.Overlay |
|||
data-slot="sheet-overlay" |
|||
className={cn( |
|||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function SheetContent({ |
|||
className, |
|||
children, |
|||
side = "right", |
|||
...props |
|||
}: React.ComponentProps<typeof SheetPrimitive.Content> & { |
|||
side?: "top" | "right" | "bottom" | "left" |
|||
}) { |
|||
return ( |
|||
<SheetPortal> |
|||
<SheetOverlay /> |
|||
<SheetPrimitive.Content |
|||
data-slot="sheet-content" |
|||
className={cn( |
|||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", |
|||
side === "right" && |
|||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", |
|||
side === "left" && |
|||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", |
|||
side === "top" && |
|||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", |
|||
side === "bottom" && |
|||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> |
|||
<XIcon className="size-4" /> |
|||
<span className="sr-only">Close</span> |
|||
</SheetPrimitive.Close> |
|||
</SheetPrimitive.Content> |
|||
</SheetPortal> |
|||
) |
|||
} |
|||
|
|||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sheet-header" |
|||
className={cn("flex flex-col gap-1.5 p-4", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sheet-footer" |
|||
className={cn("mt-auto flex flex-col gap-2 p-4", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function SheetTitle({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<typeof SheetPrimitive.Title>) { |
|||
return ( |
|||
<SheetPrimitive.Title |
|||
data-slot="sheet-title" |
|||
className={cn("text-foreground font-semibold", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function SheetDescription({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<typeof SheetPrimitive.Description>) { |
|||
return ( |
|||
<SheetPrimitive.Description |
|||
data-slot="sheet-description" |
|||
className={cn("text-muted-foreground text-sm", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { |
|||
Sheet, |
|||
SheetTrigger, |
|||
SheetClose, |
|||
SheetContent, |
|||
SheetHeader, |
|||
SheetFooter, |
|||
SheetTitle, |
|||
SheetDescription, |
|||
} |
|||
@ -0,0 +1,726 @@ |
|||
"use client"; |
|||
|
|||
import * as React from "react"; |
|||
import { Slot } from "@radix-ui/react-slot"; |
|||
import { cva, type VariantProps } from "class-variance-authority"; |
|||
import { PanelLeftIcon } from "lucide-react"; |
|||
|
|||
import { useIsMobile } from "@/hooks/use-mobile"; |
|||
import { cn } from "@/lib/utils"; |
|||
import { Button } from "@/components/ui/button"; |
|||
import { Input } from "@/components/ui/input"; |
|||
import { Separator } from "@/components/ui/separator"; |
|||
import { |
|||
Sheet, |
|||
SheetContent, |
|||
SheetDescription, |
|||
SheetHeader, |
|||
SheetTitle, |
|||
} from "@/components/ui/sheet"; |
|||
import { Skeleton } from "@/components/ui/skeleton"; |
|||
import { |
|||
Tooltip, |
|||
TooltipContent, |
|||
TooltipProvider, |
|||
TooltipTrigger, |
|||
} from "@/components/ui/tooltip"; |
|||
|
|||
const SIDEBAR_COOKIE_NAME = "sidebar_state"; |
|||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; |
|||
const SIDEBAR_WIDTH = "16rem"; |
|||
const SIDEBAR_WIDTH_MOBILE = "18rem"; |
|||
const SIDEBAR_WIDTH_ICON = "3rem"; |
|||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"; |
|||
|
|||
type SidebarContextProps = { |
|||
state: "expanded" | "collapsed"; |
|||
open: boolean; |
|||
setOpen: (open: boolean) => void; |
|||
openMobile: boolean; |
|||
setOpenMobile: (open: boolean) => void; |
|||
isMobile: boolean; |
|||
toggleSidebar: () => void; |
|||
}; |
|||
|
|||
const SidebarContext = React.createContext<SidebarContextProps | null>(null); |
|||
|
|||
function useSidebar() { |
|||
const context = React.useContext(SidebarContext); |
|||
if (!context) { |
|||
throw new Error("useSidebar must be used within a SidebarProvider."); |
|||
} |
|||
|
|||
return context; |
|||
} |
|||
|
|||
function SidebarProvider({ |
|||
defaultOpen = true, |
|||
open: openProp, |
|||
onOpenChange: setOpenProp, |
|||
className, |
|||
style, |
|||
children, |
|||
...props |
|||
}: React.ComponentProps<"div"> & { |
|||
defaultOpen?: boolean; |
|||
open?: boolean; |
|||
onOpenChange?: (open: boolean) => void; |
|||
}) { |
|||
const isMobile = useIsMobile(); |
|||
const [openMobile, setOpenMobile] = React.useState(false); |
|||
|
|||
// This is the internal state of the sidebar.
|
|||
// We use openProp and setOpenProp for control from outside the component.
|
|||
const [_open, _setOpen] = React.useState(defaultOpen); |
|||
const open = openProp ?? _open; |
|||
const setOpen = React.useCallback( |
|||
(value: boolean | ((value: boolean) => boolean)) => { |
|||
const openState = typeof value === "function" ? value(open) : value; |
|||
if (setOpenProp) { |
|||
setOpenProp(openState); |
|||
} else { |
|||
_setOpen(openState); |
|||
} |
|||
|
|||
// This sets the cookie to keep the sidebar state.
|
|||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; |
|||
}, |
|||
[setOpenProp, open], |
|||
); |
|||
|
|||
// Helper to toggle the sidebar.
|
|||
const toggleSidebar = React.useCallback(() => { |
|||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); |
|||
}, [isMobile, setOpen, setOpenMobile]); |
|||
|
|||
// Adds a keyboard shortcut to toggle the sidebar.
|
|||
React.useEffect(() => { |
|||
const handleKeyDown = (event: KeyboardEvent) => { |
|||
if ( |
|||
event.key === SIDEBAR_KEYBOARD_SHORTCUT && |
|||
(event.metaKey || event.ctrlKey) |
|||
) { |
|||
event.preventDefault(); |
|||
toggleSidebar(); |
|||
} |
|||
}; |
|||
|
|||
window.addEventListener("keydown", handleKeyDown); |
|||
return () => window.removeEventListener("keydown", handleKeyDown); |
|||
}, [toggleSidebar]); |
|||
|
|||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
|||
// This makes it easier to style the sidebar with Tailwind classes.
|
|||
const state = open ? "expanded" : "collapsed"; |
|||
|
|||
const contextValue = React.useMemo<SidebarContextProps>( |
|||
() => ({ |
|||
state, |
|||
open, |
|||
setOpen, |
|||
isMobile, |
|||
openMobile, |
|||
setOpenMobile, |
|||
toggleSidebar, |
|||
}), |
|||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], |
|||
); |
|||
|
|||
return ( |
|||
<SidebarContext.Provider value={contextValue}> |
|||
<TooltipProvider delayDuration={0}> |
|||
<div |
|||
data-slot="sidebar-wrapper" |
|||
style={ |
|||
{ |
|||
"--sidebar-width": SIDEBAR_WIDTH, |
|||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON, |
|||
...style, |
|||
} as React.CSSProperties |
|||
} |
|||
className={cn( |
|||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", |
|||
className, |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
</div> |
|||
</TooltipProvider> |
|||
</SidebarContext.Provider> |
|||
); |
|||
} |
|||
|
|||
function Sidebar({ |
|||
side = "left", |
|||
variant = "sidebar", |
|||
collapsible = "offcanvas", |
|||
className, |
|||
children, |
|||
...props |
|||
}: React.ComponentProps<"div"> & { |
|||
side?: "left" | "right"; |
|||
variant?: "sidebar" | "floating" | "inset"; |
|||
collapsible?: "offcanvas" | "icon" | "none"; |
|||
}) { |
|||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); |
|||
|
|||
if (collapsible === "none") { |
|||
return ( |
|||
<div |
|||
data-slot="sidebar" |
|||
className={cn( |
|||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", |
|||
className, |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (isMobile) { |
|||
return ( |
|||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> |
|||
<SheetContent |
|||
data-sidebar="sidebar" |
|||
data-slot="sidebar" |
|||
data-mobile="true" |
|||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" |
|||
style={ |
|||
{ |
|||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE, |
|||
} as React.CSSProperties |
|||
} |
|||
side={side} |
|||
> |
|||
<SheetHeader className="sr-only"> |
|||
<SheetTitle>Sidebar</SheetTitle> |
|||
<SheetDescription>Displays the mobile sidebar.</SheetDescription> |
|||
</SheetHeader> |
|||
<div className="flex h-full w-full flex-col">{children}</div> |
|||
</SheetContent> |
|||
</Sheet> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div |
|||
className="group peer text-sidebar-foreground hidden md:block" |
|||
data-state={state} |
|||
data-collapsible={state === "collapsed" ? collapsible : ""} |
|||
data-variant={variant} |
|||
data-side={side} |
|||
data-slot="sidebar" |
|||
> |
|||
{/* This is what handles the sidebar gap on desktop */} |
|||
<div |
|||
data-slot="sidebar-gap" |
|||
className={cn( |
|||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", |
|||
"group-data-[collapsible=offcanvas]:w-0", |
|||
"group-data-[side=right]:rotate-180", |
|||
variant === "floating" || variant === "inset" |
|||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" |
|||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", |
|||
)} |
|||
/> |
|||
<div |
|||
data-slot="sidebar-container" |
|||
className={cn( |
|||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", |
|||
side === "left" |
|||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" |
|||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", |
|||
// Adjust the padding for floating and inset variants.
|
|||
variant === "floating" || variant === "inset" |
|||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" |
|||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l border-sidebar-border", |
|||
className, |
|||
)} |
|||
{...props} |
|||
> |
|||
<div |
|||
data-sidebar="sidebar" |
|||
data-slot="sidebar-inner" |
|||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" |
|||
> |
|||
{children} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
function SidebarTrigger({ |
|||
className, |
|||
onClick, |
|||
...props |
|||
}: React.ComponentProps<typeof Button>) { |
|||
const { toggleSidebar } = useSidebar(); |
|||
|
|||
return ( |
|||
<Button |
|||
data-sidebar="trigger" |
|||
data-slot="sidebar-trigger" |
|||
variant="ghost" |
|||
size="icon" |
|||
className={cn("size-7", className)} |
|||
onClick={(event) => { |
|||
onClick?.(event); |
|||
toggleSidebar(); |
|||
}} |
|||
{...props} |
|||
> |
|||
<PanelLeftIcon /> |
|||
<span className="sr-only">Toggle Sidebar</span> |
|||
</Button> |
|||
); |
|||
} |
|||
|
|||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { |
|||
const { toggleSidebar } = useSidebar(); |
|||
|
|||
return ( |
|||
<button |
|||
data-sidebar="rail" |
|||
data-slot="sidebar-rail" |
|||
aria-label="Toggle Sidebar" |
|||
tabIndex={-1} |
|||
onClick={toggleSidebar} |
|||
title="Toggle Sidebar" |
|||
className={cn( |
|||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", |
|||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", |
|||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", |
|||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", |
|||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", |
|||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { |
|||
return ( |
|||
<main |
|||
data-slot="sidebar-inset" |
|||
className={cn( |
|||
"bg-background relative flex w-full flex-1 flex-col", |
|||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarInput({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<typeof Input>) { |
|||
return ( |
|||
<Input |
|||
data-slot="sidebar-input" |
|||
data-sidebar="input" |
|||
className={cn("bg-background h-8 w-full shadow-none", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sidebar-header" |
|||
data-sidebar="header" |
|||
className={cn("flex flex-col gap-2 p-2", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sidebar-footer" |
|||
data-sidebar="footer" |
|||
className={cn("flex flex-col gap-2 p-2", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarSeparator({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<typeof Separator>) { |
|||
return ( |
|||
<Separator |
|||
data-slot="sidebar-separator" |
|||
data-sidebar="separator" |
|||
className={cn("bg-sidebar-border mx-2 w-auto", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sidebar-content" |
|||
data-sidebar="content" |
|||
className={cn( |
|||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sidebar-group" |
|||
data-sidebar="group" |
|||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarGroupLabel({ |
|||
className, |
|||
asChild = false, |
|||
...props |
|||
}: React.ComponentProps<"div"> & { asChild?: boolean }) { |
|||
const Comp = asChild ? Slot : "div"; |
|||
|
|||
return ( |
|||
<Comp |
|||
data-slot="sidebar-group-label" |
|||
data-sidebar="group-label" |
|||
className={cn( |
|||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", |
|||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarGroupAction({ |
|||
className, |
|||
asChild = false, |
|||
...props |
|||
}: React.ComponentProps<"button"> & { asChild?: boolean }) { |
|||
const Comp = asChild ? Slot : "button"; |
|||
|
|||
return ( |
|||
<Comp |
|||
data-slot="sidebar-group-action" |
|||
data-sidebar="group-action" |
|||
className={cn( |
|||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", |
|||
// Increases the hit area of the button on mobile.
|
|||
"after:absolute after:-inset-2 md:after:hidden", |
|||
"group-data-[collapsible=icon]:hidden", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarGroupContent({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sidebar-group-content" |
|||
data-sidebar="group-content" |
|||
className={cn("w-full text-sm", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { |
|||
return ( |
|||
<ul |
|||
data-slot="sidebar-menu" |
|||
data-sidebar="menu" |
|||
className={cn("flex w-full min-w-0 flex-col gap-1", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { |
|||
return ( |
|||
<li |
|||
data-slot="sidebar-menu-item" |
|||
data-sidebar="menu-item" |
|||
className={cn("group/menu-item relative", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
const sidebarMenuButtonVariants = cva( |
|||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", |
|||
outline: |
|||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", |
|||
}, |
|||
size: { |
|||
default: "h-8 text-sm", |
|||
sm: "h-7 text-xs", |
|||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
size: "default", |
|||
}, |
|||
}, |
|||
); |
|||
|
|||
function SidebarMenuButton({ |
|||
asChild = false, |
|||
isActive = false, |
|||
variant = "default", |
|||
size = "default", |
|||
tooltip, |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<"button"> & { |
|||
asChild?: boolean; |
|||
isActive?: boolean; |
|||
tooltip?: string | React.ComponentProps<typeof TooltipContent>; |
|||
} & VariantProps<typeof sidebarMenuButtonVariants>) { |
|||
const Comp = asChild ? Slot : "button"; |
|||
const { isMobile, state } = useSidebar(); |
|||
|
|||
const button = ( |
|||
<Comp |
|||
data-slot="sidebar-menu-button" |
|||
data-sidebar="menu-button" |
|||
data-size={size} |
|||
data-active={isActive} |
|||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
|
|||
if (!tooltip) { |
|||
return button; |
|||
} |
|||
|
|||
if (typeof tooltip === "string") { |
|||
tooltip = { |
|||
children: tooltip, |
|||
}; |
|||
} |
|||
|
|||
return ( |
|||
<Tooltip> |
|||
<TooltipTrigger asChild>{button}</TooltipTrigger> |
|||
<TooltipContent |
|||
side="right" |
|||
align="center" |
|||
hidden={state !== "collapsed" || isMobile} |
|||
{...tooltip} |
|||
/> |
|||
</Tooltip> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenuAction({ |
|||
className, |
|||
asChild = false, |
|||
showOnHover = false, |
|||
...props |
|||
}: React.ComponentProps<"button"> & { |
|||
asChild?: boolean; |
|||
showOnHover?: boolean; |
|||
}) { |
|||
const Comp = asChild ? Slot : "button"; |
|||
|
|||
return ( |
|||
<Comp |
|||
data-slot="sidebar-menu-action" |
|||
data-sidebar="menu-action" |
|||
className={cn( |
|||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", |
|||
// Increases the hit area of the button on mobile.
|
|||
"after:absolute after:-inset-2 md:after:hidden", |
|||
"peer-data-[size=sm]/menu-button:top-1", |
|||
"peer-data-[size=default]/menu-button:top-1.5", |
|||
"peer-data-[size=lg]/menu-button:top-2.5", |
|||
"group-data-[collapsible=icon]:hidden", |
|||
showOnHover && |
|||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenuBadge({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="sidebar-menu-badge" |
|||
data-sidebar="menu-badge" |
|||
className={cn( |
|||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", |
|||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", |
|||
"peer-data-[size=sm]/menu-button:top-1", |
|||
"peer-data-[size=default]/menu-button:top-1.5", |
|||
"peer-data-[size=lg]/menu-button:top-2.5", |
|||
"group-data-[collapsible=icon]:hidden", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenuSkeleton({ |
|||
className, |
|||
showIcon = false, |
|||
...props |
|||
}: React.ComponentProps<"div"> & { |
|||
showIcon?: boolean; |
|||
}) { |
|||
// Random width between 50 to 90%.
|
|||
const width = React.useMemo(() => { |
|||
return `${Math.floor(Math.random() * 40) + 50}%`; |
|||
}, []); |
|||
|
|||
return ( |
|||
<div |
|||
data-slot="sidebar-menu-skeleton" |
|||
data-sidebar="menu-skeleton" |
|||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} |
|||
{...props} |
|||
> |
|||
{showIcon && ( |
|||
<Skeleton |
|||
className="size-4 rounded-md" |
|||
data-sidebar="menu-skeleton-icon" |
|||
/> |
|||
)} |
|||
<Skeleton |
|||
className="h-4 max-w-(--skeleton-width) flex-1" |
|||
data-sidebar="menu-skeleton-text" |
|||
style={ |
|||
{ |
|||
"--skeleton-width": width, |
|||
} as React.CSSProperties |
|||
} |
|||
/> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { |
|||
return ( |
|||
<ul |
|||
data-slot="sidebar-menu-sub" |
|||
data-sidebar="menu-sub" |
|||
className={cn( |
|||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", |
|||
"group-data-[collapsible=icon]:hidden", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenuSubItem({ |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<"li">) { |
|||
return ( |
|||
<li |
|||
data-slot="sidebar-menu-sub-item" |
|||
data-sidebar="menu-sub-item" |
|||
className={cn("group/menu-sub-item relative", className)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
function SidebarMenuSubButton({ |
|||
asChild = false, |
|||
size = "md", |
|||
isActive = false, |
|||
className, |
|||
...props |
|||
}: React.ComponentProps<"a"> & { |
|||
asChild?: boolean; |
|||
size?: "sm" | "md"; |
|||
isActive?: boolean; |
|||
}) { |
|||
const Comp = asChild ? Slot : "a"; |
|||
|
|||
return ( |
|||
<Comp |
|||
data-slot="sidebar-menu-sub-button" |
|||
data-sidebar="menu-sub-button" |
|||
data-size={size} |
|||
data-active={isActive} |
|||
className={cn( |
|||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", |
|||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", |
|||
size === "sm" && "text-xs", |
|||
size === "md" && "text-sm", |
|||
"group-data-[collapsible=icon]:hidden", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
export { |
|||
Sidebar, |
|||
SidebarContent, |
|||
SidebarFooter, |
|||
SidebarGroup, |
|||
SidebarGroupAction, |
|||
SidebarGroupContent, |
|||
SidebarGroupLabel, |
|||
SidebarHeader, |
|||
SidebarInput, |
|||
SidebarInset, |
|||
SidebarMenu, |
|||
SidebarMenuAction, |
|||
SidebarMenuBadge, |
|||
SidebarMenuButton, |
|||
SidebarMenuItem, |
|||
SidebarMenuSkeleton, |
|||
SidebarMenuSub, |
|||
SidebarMenuSubButton, |
|||
SidebarMenuSubItem, |
|||
SidebarProvider, |
|||
SidebarRail, |
|||
SidebarSeparator, |
|||
SidebarTrigger, |
|||
useSidebar, |
|||
}; |
|||
@ -0,0 +1,13 @@ |
|||
import { cn } from "@/lib/utils" |
|||
|
|||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { |
|||
return ( |
|||
<div |
|||
data-slot="skeleton" |
|||
className={cn("bg-accent animate-pulse rounded-md", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
export { Skeleton } |
|||
@ -0,0 +1,59 @@ |
|||
import * as React from "react" |
|||
import * as TooltipPrimitive from "@radix-ui/react-tooltip" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
function TooltipProvider({ |
|||
delayDuration = 0, |
|||
...props |
|||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { |
|||
return ( |
|||
<TooltipPrimitive.Provider |
|||
data-slot="tooltip-provider" |
|||
delayDuration={delayDuration} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
|
|||
function Tooltip({ |
|||
...props |
|||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { |
|||
return ( |
|||
<TooltipProvider> |
|||
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> |
|||
</TooltipProvider> |
|||
) |
|||
} |
|||
|
|||
function TooltipTrigger({ |
|||
...props |
|||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { |
|||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> |
|||
} |
|||
|
|||
function TooltipContent({ |
|||
className, |
|||
sideOffset = 0, |
|||
children, |
|||
...props |
|||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { |
|||
return ( |
|||
<TooltipPrimitive.Portal> |
|||
<TooltipPrimitive.Content |
|||
data-slot="tooltip-content" |
|||
sideOffset={sideOffset} |
|||
className={cn( |
|||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> |
|||
</TooltipPrimitive.Content> |
|||
</TooltipPrimitive.Portal> |
|||
) |
|||
} |
|||
|
|||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } |
|||
@ -0,0 +1,19 @@ |
|||
import * as React from "react" |
|||
|
|||
const MOBILE_BREAKPOINT = 768 |
|||
|
|||
export function useIsMobile() { |
|||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) |
|||
|
|||
React.useEffect(() => { |
|||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) |
|||
const onChange = () => { |
|||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) |
|||
} |
|||
mql.addEventListener("change", onChange) |
|||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) |
|||
return () => mql.removeEventListener("change", onChange) |
|||
}, []) |
|||
|
|||
return !!isMobile |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
export { ThemeProvider, useTheme } from "@/components/theme-provider"; |
|||
export * from "@/components/ui/badge.tsx"; |
|||
export * from "@/components/ui/sidebar"; |
|||
export { AppSidebar } from "./lib/components/index.ts"; |
|||
|
|||
// Types
|
|||
export type { |
|||
AppSidebarProps, |
|||
NavLink, |
|||
SidebarSectionProps, |
|||
} from "./lib/components/Sidebar/AppSidebar.tsx"; |
|||
|
|||
export { ThemeToggle } from "./lib/components/theme-toggle.tsx"; |
|||
@ -0,0 +1,218 @@ |
|||
import type { LucideIcon } from "lucide-react"; |
|||
import { ChevronRight } from "lucide-react"; |
|||
import type * as React from "react"; |
|||
import { Badge } from "@/components/ui/badge"; |
|||
import { |
|||
Collapsible, |
|||
CollapsibleContent, |
|||
CollapsibleTrigger, |
|||
} from "@/components/ui/collapsible"; |
|||
import { |
|||
Sidebar, |
|||
SidebarContent, |
|||
SidebarFooter, |
|||
SidebarGroup, |
|||
SidebarGroupContent, |
|||
SidebarGroupLabel, |
|||
SidebarHeader, |
|||
SidebarMenu, |
|||
SidebarMenuButton, |
|||
SidebarMenuItem, |
|||
SidebarMenuSub, |
|||
SidebarMenuSubButton, |
|||
SidebarMenuSubItem, |
|||
useSidebar, |
|||
} from "@/components/ui/sidebar"; |
|||
import { cn } from "@/lib/utils"; |
|||
|
|||
export interface AppSidebarProps { |
|||
children?: React.ReactNode; |
|||
logo?: { |
|||
src: string; |
|||
alt: string; |
|||
}; |
|||
title?: string; |
|||
navigationLabel?: string; |
|||
variant?: "sidebar" | "inset"; |
|||
footer?: React.ReactNode; |
|||
} |
|||
|
|||
export interface NavLink { |
|||
name: string; |
|||
icon: LucideIcon; |
|||
page: string; |
|||
count?: number; |
|||
active?: boolean; |
|||
disabled?: boolean; |
|||
items?: NavLink[]; |
|||
onClick?: () => void; |
|||
} |
|||
|
|||
export interface SidebarSectionProps { |
|||
label?: string; |
|||
items: NavLink[]; |
|||
} |
|||
|
|||
const AppSidebar = ({ |
|||
children, |
|||
logo, |
|||
title, |
|||
navigationLabel, |
|||
items, |
|||
footer, |
|||
variant, |
|||
...props |
|||
}: SidebarSectionProps & AppSidebarProps) => { |
|||
const { state } = useSidebar(); |
|||
const isCollapsed = state === "collapsed"; |
|||
|
|||
return ( |
|||
<Sidebar collapsible="icon" variant="sidebar" {...props}> |
|||
<SidebarHeader |
|||
className={cn( |
|||
"h-14 mt-2 flex-shrink-0 transition-all duration-300 ease-in-out", |
|||
)} |
|||
> |
|||
<div className="flex items-center"> |
|||
{logo && ( |
|||
<img |
|||
src={logo.src} |
|||
alt={logo.alt} |
|||
className="size-10 flex-shrink-0 rounded-xl" |
|||
/> |
|||
)} |
|||
{title && ( |
|||
<h2 |
|||
className={cn( |
|||
"text-xl font-semibold whitespace-nowrap", |
|||
"transition-all duration-300 ease-in-out", |
|||
isCollapsed |
|||
? "opacity-0 max-w-0 invisible ml-0" |
|||
: "opacity-100 max-w-xs visible ml-2", |
|||
)} |
|||
> |
|||
{title} |
|||
</h2> |
|||
)} |
|||
</div> |
|||
</SidebarHeader> |
|||
|
|||
<SidebarContent> |
|||
<SidebarGroup className="mt-4"> |
|||
{navigationLabel && ( |
|||
<SidebarGroupLabel |
|||
className={cn( |
|||
"transition-all duration-300 ease-in-out", |
|||
"whitespace-nowrap overflow-hidden", |
|||
isCollapsed ? "max-w-0 opacity-0 invisible" : "max-w-full", |
|||
)} |
|||
> |
|||
{navigationLabel} |
|||
</SidebarGroupLabel> |
|||
)} |
|||
<SidebarGroupContent> |
|||
<SidebarMenu> |
|||
{items.map((link) => { |
|||
// Check if this item has sub-items
|
|||
if (link.items && link.items.length > 0) { |
|||
return ( |
|||
<Collapsible |
|||
key={link.name} |
|||
asChild |
|||
defaultOpen={link.active} |
|||
className="group/collapsible" |
|||
> |
|||
<SidebarMenuItem> |
|||
<CollapsibleTrigger asChild> |
|||
<SidebarMenuButton |
|||
onClick={link.onClick} |
|||
disabled={link.disabled} |
|||
tooltip={isCollapsed ? link.name : undefined} |
|||
> |
|||
{link.icon && <link.icon className="size-5" />} |
|||
<span>{link.name}</span> |
|||
{link.count !== undefined && link.count > 0 && ( |
|||
<Badge |
|||
variant="default" |
|||
className="ml-auto bg-blue-500 text-white text-xs" |
|||
> |
|||
{link.count} |
|||
</Badge> |
|||
)} |
|||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> |
|||
</SidebarMenuButton> |
|||
</CollapsibleTrigger> |
|||
<CollapsibleContent> |
|||
<SidebarMenuSub> |
|||
{link.items.map((subLink) => ( |
|||
<SidebarMenuSubItem key={subLink.name}> |
|||
<SidebarMenuSubButton |
|||
onClick={subLink.onClick} |
|||
isActive={subLink.active} |
|||
> |
|||
{subLink.icon && ( |
|||
<subLink.icon className="size-4" /> |
|||
)} |
|||
<span>{subLink.name}</span> |
|||
{subLink.count !== undefined && |
|||
subLink.count > 0 && ( |
|||
<Badge |
|||
variant="default" |
|||
className="ml-auto bg-blue-500 text-white text-xs" |
|||
> |
|||
{subLink.count} |
|||
</Badge> |
|||
)} |
|||
</SidebarMenuSubButton> |
|||
</SidebarMenuSubItem> |
|||
))} |
|||
</SidebarMenuSub> |
|||
</CollapsibleContent> |
|||
</SidebarMenuItem> |
|||
</Collapsible> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<SidebarMenuItem key={link.name}> |
|||
<SidebarMenuButton |
|||
onClick={link.onClick} |
|||
isActive={link.active} |
|||
disabled={link.disabled} |
|||
tooltip={isCollapsed ? link.name : undefined} |
|||
> |
|||
{link.icon && <link.icon className="size-5" />} |
|||
<span>{link.name}</span> |
|||
{link.count !== undefined && link.count > 0 && ( |
|||
<Badge |
|||
variant="default" |
|||
className="ml-auto bg-blue-500 text-white text-xs" |
|||
> |
|||
{link.count} |
|||
</Badge> |
|||
)} |
|||
</SidebarMenuButton> |
|||
</SidebarMenuItem> |
|||
); |
|||
})} |
|||
</SidebarMenu> |
|||
</SidebarGroupContent> |
|||
</SidebarGroup> |
|||
|
|||
{children && ( |
|||
<div |
|||
className={cn("flex-1 min-h-0", isCollapsed && "overflow-hidden")} |
|||
> |
|||
{children} |
|||
</div> |
|||
)} |
|||
</SidebarContent> |
|||
|
|||
{footer && <SidebarFooter className="pt-4">{footer}</SidebarFooter>} |
|||
</Sidebar> |
|||
); |
|||
}; |
|||
|
|||
AppSidebar.displayName = "AppSidebar"; |
|||
|
|||
export default AppSidebar; |
|||
@ -0,0 +1 @@ |
|||
export { default as AppSidebar } from "./Sidebar/AppSidebar.tsx"; |
|||
@ -0,0 +1,38 @@ |
|||
import * as React from "react"; |
|||
import { Moon, Sun } from "lucide-react"; |
|||
|
|||
import { Button } from "@/components/ui/button"; |
|||
import { |
|||
DropdownMenu, |
|||
DropdownMenuContent, |
|||
DropdownMenuItem, |
|||
DropdownMenuTrigger, |
|||
} from "@/components/ui/dropdown-menu"; |
|||
import { useTheme } from "@/components/theme-provider"; |
|||
|
|||
export function ThemeToggle() { |
|||
const { setTheme } = useTheme(); |
|||
|
|||
return ( |
|||
<DropdownMenu> |
|||
<DropdownMenuTrigger asChild> |
|||
<Button variant="outline" size="icon"> |
|||
<Sun className="size-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> |
|||
<Moon className="absolute size-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> |
|||
<span className="sr-only">Toggle theme</span> |
|||
</Button> |
|||
</DropdownMenuTrigger> |
|||
<DropdownMenuContent align="end"> |
|||
<DropdownMenuItem onClick={() => setTheme("light")}> |
|||
Light |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem onClick={() => setTheme("dark")}> |
|||
Dark |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem onClick={() => setTheme("system")}> |
|||
System |
|||
</DropdownMenuItem> |
|||
</DropdownMenuContent> |
|||
</DropdownMenu> |
|||
); |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
@import "tailwindcss"; |
|||
@import "tw-animate-css"; |
|||
|
|||
@custom-variant dark (&:is(.dark *)); |
|||
|
|||
:root { |
|||
--background: oklch(1 0 0); |
|||
--foreground: oklch(0.145 0 0); |
|||
--card: oklch(1 0 0); |
|||
--card-foreground: oklch(0.145 0 0); |
|||
--popover: oklch(1 0 0); |
|||
--popover-foreground: oklch(0.145 0 0); |
|||
--primary: oklch(0.205 0 0); |
|||
--primary-foreground: oklch(0.985 0 0); |
|||
--secondary: oklch(0.97 0 0); |
|||
--secondary-foreground: oklch(0.205 0 0); |
|||
--muted: oklch(0.97 0 0); |
|||
--muted-foreground: oklch(0.556 0 0); |
|||
--accent: oklch(0.97 0 0); |
|||
--accent-foreground: oklch(0.205 0 0); |
|||
--destructive: oklch(0.577 0.245 27.325); |
|||
--destructive-foreground: oklch(0.577 0.245 27.325); |
|||
--border: oklch(0.922 0 0); |
|||
--input: oklch(0.922 0 0); |
|||
--ring: oklch(0.708 0 0); |
|||
--chart-1: oklch(0.646 0.222 41.116); |
|||
--chart-2: oklch(0.6 0.118 184.704); |
|||
--chart-3: oklch(0.398 0.07 227.392); |
|||
--chart-4: oklch(0.828 0.189 84.429); |
|||
--chart-5: oklch(0.769 0.188 70.08); |
|||
--radius: 0.625rem; |
|||
--sidebar: oklch(0.985 0 0); |
|||
--sidebar-foreground: oklch(0.145 0 0); |
|||
--sidebar-primary: oklch(0.205 0 0); |
|||
--sidebar-primary-foreground: oklch(0.985 0 0); |
|||
--sidebar-accent: oklch(0.97 0 0); |
|||
--sidebar-accent-foreground: oklch(0.205 0 0); |
|||
--sidebar-border: oklch(0.922 0 0); |
|||
--sidebar-ring: oklch(0.708 0 0); |
|||
} |
|||
|
|||
.dark { |
|||
--background: oklch(0.145 0 0); |
|||
--foreground: oklch(0.985 0 0); |
|||
--card: oklch(0.145 0 0); |
|||
--card-foreground: oklch(0.985 0 0); |
|||
--popover: oklch(0.145 0 0); |
|||
--popover-foreground: oklch(0.985 0 0); |
|||
--primary: oklch(0.985 0 0); |
|||
--primary-foreground: oklch(0.205 0 0); |
|||
--secondary: oklch(0.269 0 0); |
|||
--secondary-foreground: oklch(0.985 0 0); |
|||
--muted: oklch(0.269 0 0); |
|||
--muted-foreground: oklch(0.708 0 0); |
|||
--accent: oklch(0.269 0 0); |
|||
--accent-foreground: oklch(0.985 0 0); |
|||
--destructive: oklch(0.396 0.141 25.723); |
|||
--destructive-foreground: oklch(0.637 0.237 25.331); |
|||
--border: oklch(0.269 0 0); |
|||
--input: oklch(0.269 0 0); |
|||
--ring: oklch(0.439 0 0); |
|||
--chart-1: oklch(0.488 0.243 264.376); |
|||
--chart-2: oklch(0.696 0.17 162.48); |
|||
--chart-3: oklch(0.769 0.188 70.08); |
|||
--chart-4: oklch(0.627 0.265 303.9); |
|||
--chart-5: oklch(0.645 0.246 16.439); |
|||
--sidebar: oklch(0.205 0 0); |
|||
--sidebar-foreground: oklch(0.985 0 0); |
|||
--sidebar-primary: oklch(0.488 0.243 264.376); |
|||
--sidebar-primary-foreground: oklch(0.985 0 0); |
|||
--sidebar-accent: oklch(0.269 0 0); |
|||
--sidebar-accent-foreground: oklch(0.985 0 0); |
|||
--sidebar-border: oklch(0.269 0 0); |
|||
--sidebar-ring: oklch(0.439 0 0); |
|||
} |
|||
|
|||
@theme inline { |
|||
--color-background: var(--background); |
|||
--color-foreground: var(--foreground); |
|||
--color-card: var(--card); |
|||
--color-card-foreground: var(--card-foreground); |
|||
--color-popover: var(--popover); |
|||
--color-popover-foreground: var(--popover-foreground); |
|||
--color-primary: var(--primary); |
|||
--color-primary-foreground: var(--primary-foreground); |
|||
--color-secondary: var(--secondary); |
|||
--color-secondary-foreground: var(--secondary-foreground); |
|||
--color-muted: var(--muted); |
|||
--color-muted-foreground: var(--muted-foreground); |
|||
--color-accent: var(--accent); |
|||
--color-accent-foreground: var(--accent-foreground); |
|||
--color-destructive: var(--destructive); |
|||
--color-destructive-foreground: var(--destructive-foreground); |
|||
--color-border: var(--border); |
|||
--color-input: var(--input); |
|||
--color-ring: var(--ring); |
|||
--color-chart-1: var(--chart-1); |
|||
--color-chart-2: var(--chart-2); |
|||
--color-chart-3: var(--chart-3); |
|||
--color-chart-4: var(--chart-4); |
|||
--color-chart-5: var(--chart-5); |
|||
--radius-sm: calc(var(--radius) - 4px); |
|||
--radius-md: calc(var(--radius) - 2px); |
|||
--radius-lg: var(--radius); |
|||
--radius-xl: calc(var(--radius) + 4px); |
|||
--color-sidebar: var(--sidebar); |
|||
--color-sidebar-foreground: var(--sidebar-foreground); |
|||
--color-sidebar-primary: var(--sidebar-primary); |
|||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); |
|||
--color-sidebar-accent: var(--sidebar-accent); |
|||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); |
|||
--color-sidebar-border: var(--sidebar-border); |
|||
--color-sidebar-ring: var(--sidebar-ring); |
|||
} |
|||
|
|||
@layer base { |
|||
* { |
|||
@apply border-border outline-ring/50; |
|||
} |
|||
|
|||
body { |
|||
@apply bg-background text-foreground; |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
import { clsx, type ClassValue } from "clsx" |
|||
import { twMerge } from "tailwind-merge" |
|||
|
|||
export function cn(...inputs: ClassValue[]) { |
|||
return twMerge(clsx(inputs)) |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
{ |
|||
"extends": "../../tsconfig.json", |
|||
"compilerOptions": { |
|||
"target": "ES2021", |
|||
"lib": ["ES2021", "DOM"], |
|||
"jsx": "react-jsx", |
|||
"module": "ESNext", |
|||
"moduleResolution": "Bundler", |
|||
"skipLibCheck": true, |
|||
"declaration": true, |
|||
"declarationMap": true, |
|||
"emitDeclarationOnly": false, |
|||
"noUncheckedIndexedAccess": true, |
|||
"erasableSyntaxOnly": true, |
|||
"outDir": "dist", |
|||
"rootDir": "src", |
|||
"strict": true, |
|||
"types": ["react", "react-dom"], |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": ["./src/*"] |
|||
} |
|||
}, |
|||
"include": ["src"] |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
import path from "node:path"; |
|||
import tailwindcss from "@tailwindcss/vite"; |
|||
import react from "@vitejs/plugin-react"; |
|||
import { defineConfig } from "vite"; |
|||
import dts from "vite-plugin-dts"; |
|||
import { viteStaticCopy } from "vite-plugin-static-copy"; |
|||
|
|||
export default defineConfig({ |
|||
plugins: [ |
|||
react(), |
|||
tailwindcss(), |
|||
dts({ |
|||
entryRoot: "src", |
|||
outDir: "dist", |
|||
insertTypesEntry: true, |
|||
copyDtsFiles: true, |
|||
}), |
|||
viteStaticCopy({ |
|||
targets: [ |
|||
{ |
|||
src: "src/lib/theme/default.css", |
|||
dest: "theme", |
|||
}, |
|||
], |
|||
}), |
|||
], |
|||
resolve: { |
|||
alias: { |
|||
"@": path.resolve(__dirname, "./src"), |
|||
}, |
|||
}, |
|||
build: { |
|||
emptyOutDir: true, |
|||
lib: { |
|||
entry: "src/index.ts", |
|||
name: "MeshtasticUI", |
|||
formats: ["es"], |
|||
fileName: () => "index.js", |
|||
}, |
|||
rollupOptions: { |
|||
external: [ |
|||
"react", |
|||
"react-dom", |
|||
"tailwindcss", |
|||
"class-variance-authority", |
|||
"tailwind-merge", |
|||
"@radix-ui/react-slot", |
|||
], |
|||
}, |
|||
sourcemap: true, |
|||
target: "es2021", |
|||
}, |
|||
}); |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue