init commit

This commit is contained in:
2025-10-28 10:57:53 -05:00
commit 693a10957c
109 changed files with 19075 additions and 0 deletions

View File

@@ -0,0 +1 @@
[["1","2","3","4","5","6","7","8","9","10","11","12","13"],{"key":"14","value":"15"},{"key":"16","value":"17"},{"key":"18","value":"19"},{"key":"20","value":"21"},{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/separator.tsx",{"size":650,"mtime":1761443987000,"hash":"40","data":"41"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/input.tsx",{"size":925,"mtime":1761443987000,"hash":"42","data":"43"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/label.tsx",{"size":566,"mtime":1761443987000,"hash":"44","data":"45"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/components.json",{"size":308,"mtime":1761443987000,"hash":"46","data":"47"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/index.ts",{"size":167,"mtime":1761443987000,"hash":"48","data":"49"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/eslint.config.ts",{"size":256,"mtime":1761443987000,"hash":"50","data":"51"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/button.tsx",{"size":2138,"mtime":1761443987000,"hash":"52","data":"53"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/dropdown-menu.tsx",{"size":8045,"mtime":1761443987000,"hash":"54","data":"55"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/theme.tsx",{"size":5136,"mtime":1761443987000,"hash":"56","data":"57"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/tsconfig.json",{"size":213,"mtime":1761443987000,"hash":"58","data":"59"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/toast.tsx",{"size":616,"mtime":1761443987000,"hash":"60","data":"61"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/src/field.tsx",{"size":6144,"mtime":1761443987000,"hash":"62","data":"63"},"/home/gib/Documents/Code/monorepo/convex-monorepo/packages/ui/package.json",{"size":1422,"mtime":1761443987000,"hash":"64","data":"65"},"83cdb634982bdcb0442886a9c1a2ac15",{"hashOfOptions":"66"},"03c1d36d1356b80e30445b4d9896cada",{"hashOfOptions":"67"},"4faf2b2914366f3672010fda69530e83",{"hashOfOptions":"68"},"9c7797b58e9124116b585c64e6c0b829",{"hashOfOptions":"69"},"b80c04716d3fd60c9de5da63578801f5",{"hashOfOptions":"70"},"420f5beb8cfa59be129b4697f434770b",{"hashOfOptions":"71"},"c015de3bdfc4d639054cbc88829ab284",{"hashOfOptions":"72"},"2ce4e76a857d234f206c0e651539636b",{"hashOfOptions":"73"},"b62cf6fa9377430923c4b0137ae2d414",{"hashOfOptions":"74"},"a6fc50dd2d117cd2ab5544870b6eb4fb",{"hashOfOptions":"75"},"3f99d212c8aceb0666e2a52d42f73af1",{"hashOfOptions":"76"},"a47a38dceeddc2f58e8f06c2a2c47a96",{"hashOfOptions":"77"},"62e4d8f2e00d6f44e7698889dce72c88",{"hashOfOptions":"78"},"2094140157","671104514","1777924684","3986582840","634744684","2132860788","4287561166","2299778291","1203426049","4138459277","3973557695","3103518834","3508854902"]

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "unused.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"utils": "@acme/ui",
"components": "src/",
"ui": "src/"
}
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "eslint/config";
import { baseConfig } from "@acme/eslint-config/base";
import { reactConfig } from "@acme/eslint-config/react";
export default defineConfig(
{
ignores: ["dist/**"],
},
baseConfig,
reactConfig,
);

47
packages/ui/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "@acme/ui",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx",
"./dropdown-menu": "./src/dropdown-menu.tsx",
"./field": "./src/field.tsx",
"./input": "./src/input.tsx",
"./label": "./src/label.tsx",
"./separator": "./src/separator.tsx",
"./theme": "./src/theme.tsx",
"./toast": "./src/toast.tsx"
},
"license": "MIT",
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"typecheck": "tsc --noEmit --emitDeclarationOnly false",
"ui-add": "pnpm dlx shadcn@latest add && prettier src --write --list-different"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"class-variance-authority": "^0.7.1",
"radix-ui": "^1.4.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@acme/eslint-config": "workspace:*",
"@acme/prettier-config": "workspace:*",
"@acme/tsconfig": "workspace:*",
"@types/react": "catalog:react19",
"eslint": "catalog:",
"prettier": "catalog:",
"react": "catalog:react19",
"typescript": "catalog:",
"zod": "catalog:"
},
"peerDependencies": {
"react": "catalog:react19",
"zod": "catalog:"
},
"prettier": "@acme/prettier-config"
}

View File

@@ -0,0 +1,57 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { Slot as SlotPrimitive } from "radix-ui";
import { cn } from "@acme/ui";
export const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
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 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}

View File

@@ -0,0 +1,242 @@
"use client";
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@acme/ui";
export function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
export function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
export function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
export 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>
);
}
export function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
export 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}
/>
);
}
export 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>
);
}
export function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
export 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>
<DotFilledIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
export 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}
/>
);
}
export 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}
/>
);
}
export 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}
/>
);
}
export function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
export 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 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
export 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}
/>
);
}

249
packages/ui/src/field.tsx Normal file
View File

@@ -0,0 +1,249 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { cva } from "class-variance-authority";
import { cn } from "@acme/ui";
import { Label } from "@acme/ui/label";
import { Separator } from "@acme/ui/separator";
export function FieldSet({
className,
...props
}: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
export function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className,
)}
{...props}
/>
);
}
export function FieldGroup({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field data-[invalid=true]:text-destructive flex w-full gap-3",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
export function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
export function FieldContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className,
)}
{...props}
/>
);
}
export function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className,
)}
{...props}
/>
);
}
export function FieldTitle({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
export function FieldDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
export function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
export function FieldError({
className,
children,
errors: maybeErrors,
...props
}: React.ComponentProps<"div"> & {
errors?: ({ message?: string } | undefined)[];
}) {
const content = useMemo(() => {
if (children) {
return children;
}
const errors = (maybeErrors ?? []).filter((error) => error !== undefined);
if (errors.length === 0) {
return null;
}
if (errors.length === 1 && errors[0]?.message) {
return errors[0].message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error.message && <li key={index}>{error.message}</li>,
)}
</ul>
);
}, [children, maybeErrors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
);
}

4
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { cx } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: Parameters<typeof cx>) => twMerge(cx(inputs));

21
packages/ui/src/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { cn } from "@acme/ui";
export 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}
/>
);
}

21
packages/ui/src/label.tsx Normal file
View File

@@ -0,0 +1,21 @@
"use client";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@acme/ui";
export function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@acme/ui";
export 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}
/>
);
}

184
packages/ui/src/theme.tsx Normal file
View File

@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { DesktopIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons";
import * as z from "zod/v4";
import { Button } from "./button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./dropdown-menu";
const ThemeModeSchema = z.enum(["light", "dark", "auto"]);
const themeKey = "theme-mode";
export type ThemeMode = z.output<typeof ThemeModeSchema>;
export type ResolvedTheme = Exclude<ThemeMode, "auto">;
const getStoredThemeMode = (): ThemeMode => {
if (typeof window === "undefined") return "auto";
try {
const storedTheme = localStorage.getItem(themeKey);
return ThemeModeSchema.parse(storedTheme);
} catch {
return "auto";
}
};
const setStoredThemeMode = (theme: ThemeMode) => {
try {
const parsedTheme = ThemeModeSchema.parse(theme);
localStorage.setItem(themeKey, parsedTheme);
} catch {
// Silently fail if localStorage is unavailable
}
};
const getSystemTheme = () => {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
const updateThemeClass = (themeMode: ThemeMode) => {
const root = document.documentElement;
root.classList.remove("light", "dark", "auto");
const newTheme = themeMode === "auto" ? getSystemTheme() : themeMode;
root.classList.add(newTheme);
if (themeMode === "auto") {
root.classList.add("auto");
}
};
const setupPreferredListener = () => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => updateThemeClass("auto");
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
};
const getNextTheme = (current: ThemeMode): ThemeMode => {
const themes: ThemeMode[] =
getSystemTheme() === "dark"
? ["auto", "light", "dark"]
: ["auto", "dark", "light"];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return themes[(themes.indexOf(current) + 1) % themes.length]!;
};
export const themeDetectorScript = (function () {
function themeFn() {
const isValidTheme = (theme: string): theme is ThemeMode => {
const validThemes = ["light", "dark", "auto"] as const;
return validThemes.includes(theme as ThemeMode);
};
const storedTheme = localStorage.getItem("theme-mode") ?? "auto";
const validTheme = isValidTheme(storedTheme) ? storedTheme : "auto";
if (validTheme === "auto") {
const autoTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
document.documentElement.classList.add(autoTheme, "auto");
} else {
document.documentElement.classList.add(validTheme);
}
}
return `(${themeFn.toString()})();`;
})();
interface ThemeContextProps {
themeMode: ThemeMode;
resolvedTheme: ResolvedTheme;
setTheme: (theme: ThemeMode) => void;
toggleMode: () => void;
}
const ThemeContext = React.createContext<ThemeContextProps | undefined>(
undefined,
);
export function ThemeProvider({ children }: React.PropsWithChildren) {
const [themeMode, setThemeMode] = React.useState(getStoredThemeMode);
React.useEffect(() => {
if (themeMode !== "auto") return;
return setupPreferredListener();
}, [themeMode]);
const resolvedTheme = themeMode === "auto" ? getSystemTheme() : themeMode;
const setTheme = (newTheme: ThemeMode) => {
setThemeMode(newTheme);
setStoredThemeMode(newTheme);
updateThemeClass(newTheme);
};
const toggleMode = () => {
setTheme(getNextTheme(themeMode));
};
return (
<ThemeContext
value={{
themeMode,
resolvedTheme,
setTheme,
toggleMode,
}}
>
<script
dangerouslySetInnerHTML={{ __html: themeDetectorScript }}
suppressHydrationWarning
/>
{children}
</ThemeContext>
);
}
export function useTheme() {
const context = React.use(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="[&>svg]:absolute [&>svg]:size-5 [&>svg]:scale-0"
>
<SunIcon className="light:scale-100! auto:scale-0!" />
<MoonIcon className="auto:scale-0! dark:scale-100!" />
<DesktopIcon className="auto: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("auto")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

27
packages/ui/src/toast.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import type { ToasterProps } from "sonner";
import { Toaster as Sonner, toast } from "sonner";
import { useTheme } from "./theme";
export const Toaster = ({ ...props }: ToasterProps) => {
const { themeMode } = useTheme();
return (
<Sonner
theme={themeMode === "auto" ? "system" : themeMode}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { toast };

10
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"lib": ["ES2022", "dom", "dom.iterable"],
"jsx": "preserve",
"rootDir": "."
},
"include": ["src"],
"exclude": ["node_modules"]
}