common components

This commit is contained in:
shafi54 2026-05-23 14:47:12 +05:30
parent 11eca48354
commit c0fd8671e4
11 changed files with 203 additions and 78 deletions

View file

@ -22,6 +22,7 @@
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.132.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.545.0",
"react": "19.1.0",
"react-dom": "19.1.0",

View file

@ -0,0 +1,53 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "#/lib/cn";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
primary: "bg-blue-600 text-white hover:bg-blue-700",
danger: "bg-red-600 text-white hover:bg-red-700",
outline:
"border border-slate-200 bg-white text-slate-700 hover:bg-slate-50",
ghost: "text-slate-500 hover:bg-slate-100 hover:text-slate-900",
"ghost-blue":
"text-slate-500 hover:text-blue-600 hover:bg-blue-50",
"ghost-red":
"text-slate-500 hover:text-red-600 hover:bg-red-50",
"ghost-dark":
"text-slate-400 hover:bg-slate-800 hover:text-white",
},
size: {
default: "px-4 py-2",
sm: "px-3 py-1.5 text-xs",
lg: "px-6 py-2.5",
icon: "w-8 h-8 p-0",
"icon-sm": "w-4 h-4 p-0 rounded-full",
},
},
defaultVariants: {
variant: "primary",
size: "default",
},
},
);
export interface ButtonProps
extends ComponentPropsWithoutRef<"button">,
VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
},
);
Button.displayName = "Button";

View file

@ -0,0 +1,39 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "#/lib/cn";
export const inputVariants = cva(
"px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 w-full",
{
variants: {
variant: {
default:
"border-slate-200 focus:border-blue-600",
error: "border-red-600",
transparent: "border-none bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface InputProps
extends ComponentPropsWithoutRef<"input">,
VariantProps<typeof inputVariants> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, variant, type, ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(inputVariants({ variant }), className)}
{...props}
/>
);
},
);
Input.displayName = "Input";

View file

@ -0,0 +1,36 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "#/lib/cn";
export const textareaVariants = cva(
"w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white resize-y min-h-[80px] transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100",
{
variants: {
variant: {
default: "border-slate-200 focus:border-blue-600",
error: "border-red-600",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface TextareaProps
extends ComponentPropsWithoutRef<"textarea">,
VariantProps<typeof textareaVariants> {}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, variant, ...props }, ref) => {
return (
<textarea
ref={ref}
className={cn(textareaVariants({ variant }), className)}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";

View file

@ -0,0 +1,3 @@
export { Button, type ButtonProps, buttonVariants } from "./Button";
export { Input, type InputProps, inputVariants } from "./Input";
export { Textarea, type TextareaProps, textareaVariants } from "./Textarea";

View file

@ -1,5 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, Pencil, Trash2, Truck } from "lucide-react";
import { Button, buttonVariants } from "#/components/ui";
import {
useGetDistributorById,
useRemoveDistributor,
@ -56,7 +57,7 @@ function DistributorDetailsPage() {
</p>
<Link
to="/distributors"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
className={buttonVariants({ variant: "primary" })}
>
Back to Distributors
</Link>
@ -124,21 +125,14 @@ function DistributorDetailsPage() {
</div>
<div className="flex items-center gap-2.5 mt-6 pt-5 border-t border-slate-200">
<button
type="button"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
>
<Button variant="primary">
<Pencil className="w-[15px] h-[15px]" />
Edit Distributor
</button>
<button
type="button"
onClick={handleDelete}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors"
>
</Button>
<Button variant="danger" onClick={handleDelete}>
<Trash2 className="w-[15px] h-[15px]" />
Delete Distributor
</button>
</Button>
</div>
</div>
</div>

View file

@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useCreateDistributor, trpc } from "shared-react";
import { Button, Input, Textarea, buttonVariants } from "#/components/ui";
const formSchema = z.object({
agency: z.string().min(1, "Agency name is required"),
@ -81,11 +82,11 @@ function AddDistributorPage() {
Agency Name{" "}
<span className="text-red-600 ml-0.5">*</span>
</label>
<input
<Input
type="text"
variant={errors.agency ? "error" : "default"}
{...register("agency")}
placeholder="e.g. MediDistributors"
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.agency ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
/>
{errors.agency && (
<p className="text-sm text-red-600 mt-1">
@ -99,11 +100,11 @@ function AddDistributorPage() {
Contact Person{" "}
<span className="text-red-600 ml-0.5">*</span>
</label>
<input
<Input
type="text"
variant={errors.contact ? "error" : "default"}
{...register("contact")}
placeholder="e.g. Rahul Mehta"
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.contact ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
/>
{errors.contact && (
<p className="text-sm text-red-600 mt-1">
@ -117,8 +118,9 @@ function AddDistributorPage() {
Contact Mobile{" "}
<span className="text-red-600 ml-0.5">*</span>
</label>
<input
<Input
type="tel"
variant={errors.mobile ? "error" : "default"}
{...register("mobile", {
onChange: (e) => {
e.target.value = e.target.value
@ -127,7 +129,6 @@ function AddDistributorPage() {
},
})}
placeholder="e.g. 9876500001"
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.mobile ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
/>
{errors.mobile && (
<p className="text-sm text-red-600 mt-1">
@ -140,13 +141,12 @@ function AddDistributorPage() {
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Address
</label>
<textarea
<Textarea
{...register("address", {
setValueAs: (v: string) => v || null,
})}
rows={3}
placeholder="Full address of the distributor"
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white resize-y min-h-[80px] transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
/>
</div>
</div>
@ -154,20 +154,19 @@ function AddDistributorPage() {
<div className="flex justify-end gap-3 pt-5 mt-2 border-t border-slate-200">
<Link
to="/distributors"
className="inline-flex items-center px-5 py-2.5 border border-slate-200 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 transition-colors"
className={buttonVariants({ variant: "outline" })}
>
Cancel
</Link>
<button
<Button
type="submit"
disabled={createMutation.isPending}
className="inline-flex items-center gap-1.5 px-6 py-2.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Plus className="w-[15px] h-[15px]" />
{createMutation.isPending
? "Saving..."
: "Save Distributor"}
</button>
</Button>
</div>
{createMutation.error && (

View file

@ -3,6 +3,7 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { Search, Plus, Pencil, Trash2, Truck } from "lucide-react";
import { GridTable } from "#/components/GridTable";
import type { GridTableColumn } from "#/components/GridTable";
import { Button, buttonVariants } from "#/components/ui";
import {
useListDistributors,
useRemoveDistributor,
@ -68,21 +69,21 @@ function makeColumns(
size: 90,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-blue-600 hover:bg-blue-50 transition-colors"
<Button
variant="ghost-blue"
size="icon"
aria-label={`Edit ${row.agency}`}
>
<Pencil className="w-[15px] h-[15px]" />
</button>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 transition-colors"
</Button>
<Button
variant="ghost-red"
size="icon"
aria-label={`Delete ${row.agency}`}
onClick={() => onDelete(row)}
>
<Trash2 className="w-[15px] h-[15px]" />
</button>
</Button>
</div>
),
},
@ -161,7 +162,7 @@ function DistributorsIndexPage() {
</div>
<Link
to="/distributors/add"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
className={buttonVariants({ variant: "primary" })}
>
<Plus className="w-[15px] h-[15px]" />
Add Distributor

View file

@ -7,6 +7,7 @@ import {
Layers,
ImageIcon,
} from "lucide-react";
import { Button, buttonVariants } from "#/components/ui";
import { useGetStorageById, useRemoveStorage, trpc } from "shared-react";
export const Route = createFileRoute("/storage/$id")({
@ -58,7 +59,7 @@ function StorageDetailsPage() {
</p>
<Link
to="/storage"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
className={buttonVariants({ variant: "primary" })}
>
Back to Storage
</Link>
@ -91,21 +92,14 @@ function StorageDetailsPage() {
)}
</div>
<div className="flex gap-2">
<button
type="button"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
>
<Button variant="primary">
<Pencil className="w-[15px] h-[15px]" />
Edit
</button>
<button
type="button"
onClick={handleDelete}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors"
>
</Button>
<Button variant="danger" onClick={handleDelete}>
<Trash2 className="w-[15px] h-[15px]" />
Delete
</button>
</Button>
</div>
</div>

View file

@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useCreateStorage, trpc } from "shared-react";
import { Button, Input, Textarea, buttonVariants } from "#/components/ui";
const formSchema = z.object({
name: z.string().min(1, "Rack name is required"),
@ -132,11 +133,11 @@ function AddStoragePage() {
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Rack Name <span className="text-red-600 ml-0.5">*</span>
</label>
<input
<Input
type="text"
variant={errors.name ? "error" : "default"}
{...register("name")}
placeholder="e.g. Rack A-01, Cold Storage 1"
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.name ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
/>
{errors.name && (
<p className="text-sm text-red-600 mt-1">
@ -149,13 +150,12 @@ function AddStoragePage() {
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Description
</label>
<textarea
<Textarea
{...register("description", {
setValueAs: (v: string) => v || null,
})}
placeholder="Describe what this rack stores, its location, special conditions..."
rows={3}
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white resize-y min-h-[80px] transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
/>
</div>
@ -168,21 +168,22 @@ function AddStoragePage() {
onRemove={handleRemoveAlias}
/>
<div className="flex gap-2">
<input
<Input
type="text"
value={aliasInput}
onChange={(e) => setAliasInput(e.target.value)}
onKeyDown={handleAliasKeyDown}
placeholder="Type an alias and press Enter or comma"
className="flex-1 px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
className="flex-1 w-auto"
/>
<button
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddAlias}
className="px-3 py-2.5 border border-slate-200 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 transition-colors"
>
Add
</button>
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">
Common names or abbreviations for this rack
@ -198,21 +199,22 @@ function AddStoragePage() {
onRemove={handleRemoveImage}
/>
<div className="flex gap-2">
<input
<Input
type="text"
value={imageInput}
onChange={(e) => setImageInput(e.target.value)}
onKeyDown={handleImageKeyDown}
placeholder="Image filename (e.g. rack-a01-photo.jpg)"
className="flex-1 px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
className="flex-1 w-auto"
/>
<button
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddImage}
className="px-3 py-2.5 border border-slate-200 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 transition-colors"
>
Add
</button>
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">
Add filenames or URLs for rack photos
@ -220,17 +222,16 @@ function AddStoragePage() {
</div>
<div className="flex gap-3 pt-5 border-t border-slate-200">
<button
<Button
type="submit"
disabled={createMutation.isPending}
className="inline-flex items-center gap-1.5 px-5 py-2.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Plus className="w-[15px] h-[15px]" />
{createMutation.isPending ? "Saving..." : "Save Rack"}
</button>
</Button>
<Link
to="/storage"
className="inline-flex items-center px-5 py-2.5 border border-slate-200 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 transition-colors"
className={buttonVariants({ variant: "outline" })}
>
Cancel
</Link>
@ -262,13 +263,14 @@ function AliasList({
className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-100 rounded-full text-sm"
>
{alias}
<button
<Button
type="button"
variant="ghost-red"
size="icon-sm"
onClick={() => onRemove(i)}
className="w-4 h-4 flex items-center justify-center rounded-full hover:bg-red-600 hover:text-white transition-colors text-slate-500"
>
<X className="w-3 h-3" />
</button>
</Button>
</span>
))}
</div>
@ -291,13 +293,15 @@ function ImageList({
className="relative w-20 h-20 rounded-md border border-slate-200 bg-slate-50 flex items-center justify-center"
>
<ImageIcon className="w-8 h-8 text-slate-300" />
<button
<Button
type="button"
variant="danger"
size="icon-sm"
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full text-xs"
onClick={() => onRemove(i)}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-600 text-white flex items-center justify-center text-xs hover:bg-red-700 transition-colors"
>
<X className="w-3 h-3" />
</button>
</Button>
</div>
))}
</div>

View file

@ -3,6 +3,7 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { Search, Plus, Pencil, Trash2, Warehouse } from "lucide-react";
import { GridTable } from "#/components/GridTable";
import type { GridTableColumn } from "#/components/GridTable";
import { Button, buttonVariants } from "#/components/ui";
import { useListStorage, useRemoveStorage, trpc } from "shared-react";
interface Rack {
@ -61,21 +62,21 @@ function makeColumns(
size: 80,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-blue-600 hover:bg-blue-50 transition-colors"
<Button
variant="ghost-blue"
size="icon"
aria-label={`Edit ${row.name}`}
>
<Pencil className="w-[15px] h-[15px]" />
</button>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 transition-colors"
</Button>
<Button
variant="ghost-red"
size="icon"
aria-label={`Delete ${row.name}`}
onClick={() => onDelete(row)}
>
<Trash2 className="w-[15px] h-[15px]" />
</button>
</Button>
</div>
),
},
@ -153,7 +154,7 @@ function StorageIndexPage() {
</div>
<Link
to="/storage/add"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
className={buttonVariants({ variant: "primary" })}
>
<Plus className="w-[15px] h-[15px]" />
Add Rack