feat: add reusable UI primitives for admin and public site
Admin (ui.tsx): AdminInput, AdminSelect, AdminTextarea, AdminButton, AdminModal + adminStyles token object for consistent styling. Public (ModalBase.tsx): Shared modal with overlay, backdrop blur, focus trap, ESC handling, and portal rendering. Public (TabButton.tsx): Unified active/inactive tab with gold styling.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Admin UI Primitives
|
||||
*
|
||||
* Single source of truth for admin panel styling.
|
||||
* Every input, select, button, badge, card, and modal in /admin should use these.
|
||||
*/
|
||||
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
/* ============================== */
|
||||
/* Style tokens */
|
||||
/* ============================== */
|
||||
|
||||
export const adminStyles = {
|
||||
/** Standard input — full width, rounded-lg */
|
||||
input:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
|
||||
|
||||
/** Compact input — smaller padding, text-sm */
|
||||
inputSm:
|
||||
"rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600",
|
||||
|
||||
/** Dashed input — for "add new" fields */
|
||||
inputDashed:
|
||||
"flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600",
|
||||
|
||||
/** Textarea — same as input + resize-none */
|
||||
textarea:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
|
||||
|
||||
/** Native select */
|
||||
select:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]",
|
||||
|
||||
/** Select option */
|
||||
option: "bg-white dark:bg-neutral-900",
|
||||
|
||||
/** Primary button — gold solid */
|
||||
btnPrimary:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-all hover:bg-gold-light hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
|
||||
/** Secondary button — outline */
|
||||
btnSecondary:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700",
|
||||
|
||||
/** Small gold accent button */
|
||||
btnGoldSm:
|
||||
"rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed dark:text-gold",
|
||||
|
||||
/** Cancel/muted small button */
|
||||
btnCancelSm:
|
||||
"rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25",
|
||||
|
||||
/** Danger button */
|
||||
btnDanger:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-500",
|
||||
|
||||
/** Card container */
|
||||
card:
|
||||
"rounded-xl border border-neutral-200 bg-white p-5 dark:border-white/10 dark:bg-neutral-900",
|
||||
|
||||
/** Modal overlay */
|
||||
modalOverlay:
|
||||
"fixed inset-0 z-50 flex items-center justify-center p-4",
|
||||
|
||||
/** Modal backdrop */
|
||||
modalBackdrop:
|
||||
"absolute inset-0 bg-black/70 backdrop-blur-sm",
|
||||
|
||||
/** Modal content */
|
||||
modalContent:
|
||||
"relative w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]",
|
||||
|
||||
/** Modal close button */
|
||||
modalClose:
|
||||
"absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 transition-colors cursor-pointer dark:hover:bg-white/[0.06] dark:hover:text-white",
|
||||
|
||||
/** Label */
|
||||
label:
|
||||
"block text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400",
|
||||
|
||||
/** Section heading in admin */
|
||||
sectionTitle:
|
||||
"text-lg font-bold text-neutral-900 dark:text-white",
|
||||
|
||||
/** Dashed add-item button */
|
||||
addButton:
|
||||
"flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40",
|
||||
} as const;
|
||||
|
||||
/* ============================== */
|
||||
/* Input components */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminInputProps extends ComponentPropsWithoutRef<"input"> {
|
||||
variant?: "default" | "sm" | "dashed";
|
||||
}
|
||||
|
||||
export const AdminInput = forwardRef<HTMLInputElement, AdminInputProps>(
|
||||
function AdminInput({ variant = "default", className = "", ...props }, ref) {
|
||||
const base =
|
||||
variant === "sm" ? adminStyles.inputSm
|
||||
: variant === "dashed" ? adminStyles.inputDashed
|
||||
: adminStyles.input;
|
||||
return <input ref={ref} className={`${base} ${className}`} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
interface AdminTextareaProps extends ComponentPropsWithoutRef<"textarea"> {
|
||||
autoResize?: boolean;
|
||||
}
|
||||
|
||||
export const AdminTextarea = forwardRef<HTMLTextAreaElement, AdminTextareaProps>(
|
||||
function AdminTextarea({ autoResize, className = "", ...props }, ref) {
|
||||
function handleInput(e: React.FormEvent<HTMLTextAreaElement>) {
|
||||
if (autoResize) {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}
|
||||
}
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`${adminStyles.textarea} ${className}`}
|
||||
{...props}
|
||||
{...(autoResize ? { onInput: handleInput } : {})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface AdminSelectProps extends ComponentPropsWithoutRef<"select"> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AdminSelect = forwardRef<HTMLSelectElement, AdminSelectProps>(
|
||||
function AdminSelect({ className = "", children, ...props }, ref) {
|
||||
return (
|
||||
<select ref={ref} className={`${adminStyles.select} ${className}`} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* ============================== */
|
||||
/* Button components */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
variant?: "primary" | "secondary" | "danger" | "goldSm" | "cancelSm";
|
||||
}
|
||||
|
||||
export function AdminButton({ variant = "primary", className = "", ...props }: AdminButtonProps) {
|
||||
const base =
|
||||
variant === "secondary" ? adminStyles.btnSecondary
|
||||
: variant === "danger" ? adminStyles.btnDanger
|
||||
: variant === "goldSm" ? adminStyles.btnGoldSm
|
||||
: variant === "cancelSm" ? adminStyles.btnCancelSm
|
||||
: adminStyles.btnPrimary;
|
||||
return <button className={`${base} ${className}`} {...props} />;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Modal component */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
maxWidth?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminModal({ open, onClose, title, maxWidth = "max-w-sm", children }: AdminModalProps) {
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={adminStyles.modalOverlay} onClick={onClose}>
|
||||
<div className={adminStyles.modalBackdrop} />
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className={`${adminStyles.modalContent} ${maxWidth}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<button onClick={onClose} className={adminStyles.modalClose} aria-label="Закрыть">
|
||||
<X size={16} />
|
||||
</button>
|
||||
{title && <h3 className="text-sm font-bold text-neutral-900 dark:text-white mb-4">{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
interface ModalBaseProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ariaLabel: string;
|
||||
maxWidth?: string;
|
||||
children: React.ReactNode;
|
||||
/** Hide the default close button (e.g. when content has its own) */
|
||||
hideClose?: boolean;
|
||||
}
|
||||
|
||||
export function ModalBase({ open, onClose, ariaLabel, maxWidth = "max-w-md", children, hideClose }: ModalBaseProps) {
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className={`modal-content relative w-full ${maxWidth} rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-neutral-950 p-6 sm:p-8 shadow-2xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!hideClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 dark:text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
interface TabButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const activeClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
|
||||
const inactiveClass =
|
||||
"border border-neutral-300 text-neutral-600 hover:border-neutral-400 hover:text-neutral-800 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20";
|
||||
|
||||
export function TabButton({ active, onClick, children, className = "" }: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||
active ? activeClass : inactiveClass
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user