diff options
Diffstat (limited to 'packages/ui/src/components')
| -rw-r--r-- | packages/ui/src/components/bottom-bar.tsx | 44 | ||||
| -rw-r--r-- | packages/ui/src/components/instance-creation-modal.tsx | 544 | ||||
| -rw-r--r-- | packages/ui/src/components/instance-editor-modal.tsx | 29 | ||||
| -rw-r--r-- | packages/ui/src/components/particle-background.tsx | 68 | ||||
| -rw-r--r-- | packages/ui/src/components/sidebar.tsx | 12 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/accordion.tsx | 77 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/alert-dialog.tsx | 186 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/button.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/field.tsx | 46 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/label.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/radio-group.tsx | 14 |
11 files changed, 364 insertions, 660 deletions
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 8f70985..f73ace4 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -1,10 +1,10 @@ import { Play, User, XIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/models/auth"; +import { useGameStore } from "@/models/game"; import { useInstanceStore } from "@/models/instance"; -import { useGameStore } from "@/stores/game-store"; import { LoginModal } from "./login-modal"; import { Button } from "./ui/button"; import { @@ -19,27 +19,17 @@ import { Spinner } from "./ui/spinner"; export function BottomBar() { const account = useAuthStore((state) => state.account); - const instances = useInstanceStore((state) => state.instances); - const activeInstance = useInstanceStore((state) => state.activeInstance); - const setActiveInstance = useInstanceStore((state) => state.setActiveInstance); - const selectedVersion = useGameStore((state) => state.selectedVersion); - const setSelectedVersion = useGameStore((state) => state.setSelectedVersion); - const startGame = useGameStore((state) => state.startGame); - const stopGame = useGameStore((state) => state.stopGame); - const runningInstanceId = useGameStore((state) => state.runningInstanceId); - const launchingInstanceId = useGameStore((state) => state.launchingInstanceId); - const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); - const [showLoginModal, setShowLoginModal] = useState(false); - - useEffect(() => { - const nextVersion = activeInstance?.versionId ?? ""; - if (selectedVersion === nextVersion) { - return; - } + const { instances, activeInstance, setActiveInstance } = useInstanceStore(); + const { + runningInstanceId, + launchingInstanceId, + stoppingInstanceId, + startGame, + stopGame, + } = useGameStore(); - setSelectedVersion(nextVersion); - }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]); + const [showLoginModal, setShowLoginModal] = useState(false); const handleInstanceChange = useCallback( async (instanceId: string) => { @@ -47,7 +37,9 @@ export function BottomBar() { return; } - const nextInstance = instances.find((instance) => instance.id === instanceId); + const nextInstance = instances.find( + (instance) => instance.id === instanceId, + ); if (!nextInstance) { return; } @@ -68,13 +60,7 @@ export function BottomBar() { return; } - await startGame( - account, - () => setShowLoginModal(true), - activeInstance.id, - selectedVersion || activeInstance.versionId, - () => undefined, - ); + await startGame(activeInstance.id, activeInstance.versionId ?? ""); }; const handleStopGame = async () => { diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx deleted file mode 100644 index 7c46d0f..0000000 --- a/packages/ui/src/components/instance-creation-modal.tsx +++ /dev/null @@ -1,544 +0,0 @@ -import { Loader2, Search } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { - getFabricLoadersForVersion, - getForgeVersionsForGame, - installFabric, - installForge, - installVersion, -} from "@/client"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { useInstanceStore } from "@/models/instance"; -import { useGameStore } from "@/stores/game-store"; -import type { - FabricLoaderEntry, - ForgeVersion as ForgeVersionEntry, - Version, -} from "@/types"; - -interface Props { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function InstanceCreationModal({ open, onOpenChange }: Props) { - const gameStore = useGameStore(); - const instancesStore = useInstanceStore(); - - // Steps: 1 = name, 2 = version, 3 = mod loader - const [step, setStep] = useState<number>(1); - - // Step 1 - const [instanceName, setInstanceName] = useState<string>(""); - - // Step 2 - const [versionSearch, setVersionSearch] = useState<string>(""); - const [versionFilter, setVersionFilter] = useState< - "all" | "release" | "snapshot" - >("release"); - const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>( - null, - ); - - // Step 3 - const [modLoaderType, setModLoaderType] = useState< - "vanilla" | "fabric" | "forge" - >("vanilla"); - const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]); - const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]); - const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>(""); - const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>(""); - const [loadingLoaders, setLoadingLoaders] = useState(false); - - const loadModLoaders = useCallback(async () => { - if (!selectedVersionUI) return; - setLoadingLoaders(true); - setFabricLoaders([]); - setForgeVersions([]); - try { - if (modLoaderType === "fabric") { - const loaders = await getFabricLoadersForVersion(selectedVersionUI.id); - setFabricLoaders(loaders || []); - if (loaders && loaders.length > 0) { - setSelectedFabricLoader(loaders[0].loader.version); - } else { - setSelectedFabricLoader(""); - } - } else if (modLoaderType === "forge") { - const versions = await getForgeVersionsForGame(selectedVersionUI.id); - setForgeVersions(versions || []); - if (versions && versions.length > 0) { - // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here. - setSelectedForgeLoader(versions[0].version); - } else { - setSelectedForgeLoader(""); - } - } - } catch (e) { - console.error("Failed to load mod loaders:", e); - toast.error("Failed to fetch mod loader versions"); - } finally { - setLoadingLoaders(false); - } - }, [modLoaderType, selectedVersionUI]); - - // When entering step 3 and a base version exists, fetch loaders if needed - useEffect(() => { - if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) { - loadModLoaders(); - } - }, [step, modLoaderType, selectedVersionUI, loadModLoaders]); - - // Creating state - const [creating, setCreating] = useState(false); - const [errorMessage, setErrorMessage] = useState<string>(""); - - // Derived filtered versions - const filteredVersions = useMemo(() => { - const all = gameStore.versions || []; - let list = all.slice(); - if (versionFilter !== "all") { - list = list.filter((v) => v.type === versionFilter); - } - if (versionSearch.trim()) { - const q = versionSearch.trim().toLowerCase().replace(/。/g, "."); - list = list.filter((v) => v.id.toLowerCase().includes(q)); - } - return list; - }, [gameStore.versions, versionFilter, versionSearch]); - - // Reset when opened/closed - useEffect(() => { - if (open) { - // ensure versions are loaded - gameStore.loadVersions(); - setStep(1); - setInstanceName(""); - setVersionSearch(""); - setVersionFilter("release"); - setSelectedVersionUI(null); - setModLoaderType("vanilla"); - setFabricLoaders([]); - setForgeVersions([]); - setSelectedFabricLoader(""); - setSelectedForgeLoader(""); - setErrorMessage(""); - setCreating(false); - } - }, [open, gameStore.loadVersions]); - - function validateStep1(): boolean { - if (!instanceName.trim()) { - setErrorMessage("Please enter an instance name"); - return false; - } - setErrorMessage(""); - return true; - } - - function validateStep2(): boolean { - if (!selectedVersionUI) { - setErrorMessage("Please select a Minecraft version"); - return false; - } - setErrorMessage(""); - return true; - } - - async function handleNext() { - setErrorMessage(""); - if (step === 1) { - if (!validateStep1()) return; - setStep(2); - } else if (step === 2) { - if (!validateStep2()) return; - setStep(3); - } - } - - function handleBack() { - setErrorMessage(""); - setStep((s) => Math.max(1, s - 1)); - } - - async function handleCreate() { - if (!validateStep1() || !validateStep2()) return; - setCreating(true); - setErrorMessage(""); - - try { - // Step 1: create instance - const instance = await instancesStore.create(instanceName.trim()); - - // If selectedVersion provided, install it - if (selectedVersionUI && instance) { - try { - await installVersion(instance?.id, selectedVersionUI.id); - } catch (err) { - console.error("Failed to install base version:", err); - // continue - instance created but version install failed - toast.error( - `Failed to install version ${selectedVersionUI.id}: ${String(err)}`, - ); - } - } - - // If mod loader selected, install it - if (modLoaderType === "fabric" && selectedFabricLoader && instance) { - try { - await installFabric( - instance?.id, - selectedVersionUI?.id ?? "", - selectedFabricLoader, - ); - } catch (err) { - console.error("Failed to install Fabric:", err); - toast.error(`Failed to install Fabric: ${String(err)}`); - } - } else if (modLoaderType === "forge" && selectedForgeLoader && instance) { - try { - await installForge( - instance?.id, - selectedVersionUI?.id ?? "", - selectedForgeLoader, - ); - } catch (err) { - console.error("Failed to install Forge:", err); - toast.error(`Failed to install Forge: ${String(err)}`); - } - } - - // Refresh instances list - await instancesStore.refresh(); - - toast.success("Instance created successfully"); - onOpenChange(false); - } catch (e) { - console.error("Failed to create instance:", e); - setErrorMessage(String(e)); - toast.error(`Failed to create instance: ${e}`); - } finally { - setCreating(false); - } - } - - // UI pieces - const StepIndicator = () => ( - <div className="flex gap-2 w-full"> - <div - className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> - <div - className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> - <div - className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> - </div> - ); - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden"> - <DialogHeader> - <DialogTitle>Create New Instance</DialogTitle> - <DialogDescription> - Multi-step wizard — create an instance and optionally install a - version or mod loader. - </DialogDescription> - </DialogHeader> - - <div className="px-6"> - <div className="pt-4 pb-6"> - <StepIndicator /> - </div> - - {/* Step 1 - Name */} - {step === 1 && ( - <div className="space-y-4"> - <div> - <label - htmlFor="instance-name" - className="block text-sm font-medium mb-2" - > - Instance Name - </label> - <Input - id="instance-name" - placeholder="My Minecraft Instance" - value={instanceName} - onChange={(e) => setInstanceName(e.target.value)} - disabled={creating} - /> - </div> - <p className="text-xs text-muted-foreground"> - Give your instance a memorable name. - </p> - </div> - )} - - {/* Step 2 - Version selection */} - {step === 2 && ( - <div className="space-y-4"> - <div className="flex gap-3"> - <div className="relative flex-1"> - <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /> - <Input - value={versionSearch} - onChange={(e) => setVersionSearch(e.target.value)} - placeholder="Search versions..." - className="pl-9" - /> - </div> - - <div className="flex gap-2"> - <Button - type="button" - variant={versionFilter === "all" ? "default" : "outline"} - onClick={() => setVersionFilter("all")} - > - All - </Button> - <Button - type="button" - variant={ - versionFilter === "release" ? "default" : "outline" - } - onClick={() => setVersionFilter("release")} - > - Release - </Button> - <Button - type="button" - variant={ - versionFilter === "snapshot" ? "default" : "outline" - } - onClick={() => setVersionFilter("snapshot")} - > - Snapshot - </Button> - </div> - </div> - - <ScrollArea className="max-h-[36vh]"> - <div className="space-y-2 py-2"> - {gameStore.versions.length === 0 ? ( - <div className="flex items-center justify-center py-8 text-muted-foreground"> - <Loader2 className="animate-spin mr-2" /> - Loading versions... - </div> - ) : filteredVersions.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - No matching versions found - </div> - ) : ( - filteredVersions.map((v) => { - const isSelected = selectedVersionUI?.id === v.id; - return ( - <button - key={v.id} - type="button" - onClick={() => setSelectedVersionUI(v)} - className={`w-full text-left p-3 rounded-lg border transition-colors ${ - isSelected - ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200" - : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60" - }`} - > - <div className="flex items-center justify-between"> - <div> - <div className="font-mono font-bold">{v.id}</div> - <div className="text-xs text-muted-foreground mt-1"> - {v.type}{" "} - {v.releaseTime - ? ` • ${new Date(v.releaseTime).toLocaleDateString()}` - : ""} - </div> - </div> - {v.javaVersion && ( - <div className="text-sm"> - Java {v.javaVersion} - </div> - )} - </div> - </button> - ); - }) - )} - </div> - </ScrollArea> - </div> - )} - - {/* Step 3 - Mod loader */} - {step === 3 && ( - <div className="space-y-4"> - <div> - <div className="text-sm font-medium mb-2">Mod Loader Type</div> - <div className="flex gap-3"> - <Button - type="button" - variant={ - modLoaderType === "vanilla" ? "default" : "outline" - } - onClick={() => setModLoaderType("vanilla")} - > - Vanilla - </Button> - <Button - type="button" - variant={modLoaderType === "fabric" ? "default" : "outline"} - onClick={() => setModLoaderType("fabric")} - > - Fabric - </Button> - <Button - type="button" - variant={modLoaderType === "forge" ? "default" : "outline"} - onClick={() => setModLoaderType("forge")} - > - Forge - </Button> - </div> - </div> - - {modLoaderType === "fabric" && ( - <div> - {loadingLoaders ? ( - <div className="flex items-center gap-2"> - <Loader2 className="animate-spin" /> - Loading Fabric versions... - </div> - ) : fabricLoaders.length > 0 ? ( - <div className="space-y-2"> - <select - value={selectedFabricLoader} - onChange={(e) => - setSelectedFabricLoader(e.target.value) - } - className="w-full px-3 py-2 rounded border bg-transparent" - > - {fabricLoaders.map((f) => ( - <option - key={f.loader.version} - value={f.loader.version} - > - {f.loader.version}{" "} - {f.loader.stable ? "(Stable)" : "(Beta)"} - </option> - ))} - </select> - </div> - ) : ( - <p className="text-sm text-muted-foreground"> - No Fabric loaders available for this version - </p> - )} - </div> - )} - - {modLoaderType === "forge" && ( - <div> - {loadingLoaders ? ( - <div className="flex items-center gap-2"> - <Loader2 className="animate-spin" /> - Loading Forge versions... - </div> - ) : forgeVersions.length > 0 ? ( - <div className="space-y-2"> - <select - value={selectedForgeLoader} - onChange={(e) => setSelectedForgeLoader(e.target.value)} - className="w-full px-3 py-2 rounded border bg-transparent" - > - {forgeVersions.map((f) => ( - // binding ForgeVersion uses `version` as the identifier - <option key={f.version} value={f.version}> - {f.version} - </option> - ))} - </select> - </div> - ) : ( - <p className="text-sm text-muted-foreground"> - No Forge versions available for this version - </p> - )} - </div> - )} - </div> - )} - - {errorMessage && ( - <div className="text-sm text-red-400 mt-3">{errorMessage}</div> - )} - </div> - - <DialogFooter> - <div className="w-full flex justify-between items-center"> - <div> - <Button - type="button" - variant="ghost" - onClick={() => { - // cancel - onOpenChange(false); - }} - disabled={creating} - > - Cancel - </Button> - </div> - - <div className="flex gap-2"> - {step > 1 && ( - <Button - type="button" - variant="outline" - onClick={handleBack} - disabled={creating} - > - Back - </Button> - )} - - {step < 3 ? ( - <Button type="button" onClick={handleNext} disabled={creating}> - Next - </Button> - ) : ( - <Button - type="button" - onClick={handleCreate} - disabled={creating} - > - {creating ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - Creating... - </> - ) : ( - "Create" - )} - </Button> - )} - </div> - </div> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} - -export default InstanceCreationModal; diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx index d964185..105d7e9 100644 --- a/packages/ui/src/components/instance-editor-modal.tsx +++ b/packages/ui/src/components/instance-editor-modal.tsx @@ -1,8 +1,12 @@ -import { invoke } from "@tauri-apps/api/core"; +import { toNumber } from "es-toolkit/compat"; import { Folder, Loader2, Save, Trash2, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; - +import { + deleteInstanceFile, + listInstanceDirectory, + openFileExplorer, +} from "@/client"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -14,8 +18,6 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; - -import { toNumber } from "@/lib/tsrs-utils"; import { useInstanceStore } from "@/models/instance"; import { useSettingsStore } from "@/models/settings"; import type { FileInfo } from "../types/bindings/core"; @@ -94,14 +96,11 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { if (!instance) return; setLoadingFiles(true); try { - const files = await invoke<FileInfo[]>("list_instance_directory", { - instanceId: instance.id, - folder, - }); - setFileList(files || []); + const files = await listInstanceDirectory(instance.id, folder); + setFileList(files); } catch (err) { console.error("Failed to load files:", err); - toast.error("Failed to load files: " + String(err)); + toast.error(`Failed to load files: ${String(err)}`); setFileList([]); } finally { setLoadingFiles(false); @@ -135,13 +134,13 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { } setDeletingPath(filePath); try { - await invoke("delete_instance_file", { path: filePath }); + await deleteInstanceFile(filePath); // refresh the currently selected folder await loadFileList(selectedFileFolder); toast.success("Deleted"); } catch (err) { console.error("Failed to delete file:", err); - toast.error("Failed to delete file: " + String(err)); + toast.error(`Failed to delete file: ${String(err)}`); } finally { setDeletingPath(null); } @@ -149,10 +148,10 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { async function openInExplorer(filePath: string) { try { - await invoke("open_file_explorer", { path: filePath }); + await openFileExplorer(filePath); } catch (err) { console.error("Failed to open in explorer:", err); - toast.error("Failed to open file explorer: " + String(err)); + toast.error(`Failed to open file explorer: ${String(err)}`); } } @@ -184,7 +183,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { } catch (err) { console.error("Failed to save instance:", err); setErrorMessage(String(err)); - toast.error("Failed to save instance: " + String(err)); + toast.error(`Failed to save instance: ${String(err)}`); } finally { setSaving(false); } diff --git a/packages/ui/src/components/particle-background.tsx b/packages/ui/src/components/particle-background.tsx index 2e0b15a..2bf6793 100644 --- a/packages/ui/src/components/particle-background.tsx +++ b/packages/ui/src/components/particle-background.tsx @@ -1,63 +1,55 @@ -import { useEffect, useRef } from "react"; -import { SaturnEffect } from "../lib/effects/SaturnEffect"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { SaturnEffect } from "@/lib/effects/saturn"; -export function ParticleBackground() { +const SaturnEffectContext = createContext<SaturnEffect | null>(null); + +export function useSaturnEffect() { + return useContext(SaturnEffectContext); +} + +export function ParticleBackground({ + children, +}: { + children?: React.ReactNode; +}) { const canvasRef = useRef<HTMLCanvasElement | null>(null); - const effectRef = useRef<SaturnEffect | null>(null); + const [effect, setEffect] = useState<SaturnEffect | null>(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - // Instantiate SaturnEffect and attach to canvas - let effect: SaturnEffect | null = null; + let saturnEffect: SaturnEffect | null = null; try { - effect = new SaturnEffect(canvas); - effectRef.current = effect; + saturnEffect = new SaturnEffect(canvas); + setEffect(saturnEffect); } catch (err) { - // If effect fails, silently degrade (keep background blank) - // eslint-disable-next-line no-console console.warn("SaturnEffect initialization failed:", err); } const resizeHandler = () => { - if (effectRef.current) { - try { - effectRef.current.resize(window.innerWidth, window.innerHeight); - } catch { - // ignore - } - } + saturnEffect?.resize(window.innerWidth, window.innerHeight); }; window.addEventListener("resize", resizeHandler); - // Expose getter for HomeView interactions (getSaturnEffect) - // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up - ( - window as unknown as { getSaturnEffect?: () => SaturnEffect | null } - ).getSaturnEffect = () => effectRef.current; - return () => { window.removeEventListener("resize", resizeHandler); - if (effectRef.current) { - try { - effectRef.current.destroy(); - } catch { - // ignore - } - } - effectRef.current = null; - ( - window as unknown as { getSaturnEffect?: () => SaturnEffect | null } - ).getSaturnEffect = undefined; + saturnEffect?.destroy(); + + setEffect(null); }; }, []); return ( - <canvas - ref={canvasRef} - className="absolute inset-0 z-0 pointer-events-none" - /> + <SaturnEffectContext.Provider value={effect}> + <canvas + ref={canvasRef} + className="absolute inset-0 -z-10 pointer-events-none" + /> + {children} + </SaturnEffectContext.Provider> ); } + +export default ParticleBackground; diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx index d81156f..e615274 100644 --- a/packages/ui/src/components/sidebar.tsx +++ b/packages/ui/src/components/sidebar.tsx @@ -23,10 +23,6 @@ function NavItem({ Icon, label, to }: NavItemProps) { const location = useLocation(); const isActive = location.pathname === to; - const handleClick = () => { - navigate(to); - }; - return ( <Button variant="ghost" @@ -35,7 +31,7 @@ function NavItem({ Icon, label, to }: NavItemProps) { isActive && "relative bg-accent", )} size="lg" - onClick={handleClick} + onClick={() => navigate(to)} > <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} /> <span className="hidden lg:block text-sm relative z-10">{label}</span> @@ -185,7 +181,11 @@ export function Sidebar() { <div className="w-full lg:px-3 flex-1 flex flex-col justify-end"> <DropdownMenu> - <DropdownMenuTrigger render={renderUserAvatar()} className="w-full"> + <DropdownMenuTrigger + render={renderUserAvatar()} + nativeButton={false} + className="w-full" + > Open </DropdownMenuTrigger> <DropdownMenuContent align="end" side="right" sideOffset={20}> diff --git a/packages/ui/src/components/ui/accordion.tsx b/packages/ui/src/components/ui/accordion.tsx new file mode 100644 index 0000000..02ba45c --- /dev/null +++ b/packages/ui/src/components/ui/accordion.tsx @@ -0,0 +1,77 @@ +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + <AccordionPrimitive.Root + data-slot="accordion" + className={cn("flex w-full flex-col", className)} + {...props} + /> + ); +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + <AccordionPrimitive.Item + data-slot="accordion-item" + className={cn("not-last:border-b", className)} + {...props} + /> + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + data-slot="accordion-trigger" + className={cn( + "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-none border border-transparent py-2.5 text-left text-xs font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground", + className, + )} + {...props} + > + {children} + <ChevronDownIcon + data-slot="accordion-trigger-icon" + className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" + /> + <ChevronUpIcon + data-slot="accordion-trigger-icon" + className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" + /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> + ); +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + <AccordionPrimitive.Panel + data-slot="accordion-content" + className="overflow-hidden text-xs data-open:animate-accordion-down data-closed:animate-accordion-up" + {...props} + > + <div + className={cn( + "h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", + className, + )} + > + {children} + </div> + </AccordionPrimitive.Panel> + ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/src/components/ui/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..27c9f77 --- /dev/null +++ b/packages/ui/src/components/ui/alert-dialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> + ); +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + <AlertDialogPrimitive.Backdrop + data-slot="alert-dialog-overlay" + className={cn( + "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", + className, + )} + {...props} + /> + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm"; +}) { + return ( + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Popup + data-slot="alert-dialog-content" + data-size={size} + className={cn( + "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + className, + )} + {...props} + /> + </AlertDialogPortal> + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="alert-dialog-header" + className={cn( + "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", + className, + )} + {...props} + /> + ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="alert-dialog-footer" + className={cn( + "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end", + className, + )} + {...props} + /> + ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="alert-dialog-media" + className={cn( + "mb-2 inline-flex size-10 items-center justify-center rounded-none bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", + className, + )} + {...props} + /> + ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { + return ( + <AlertDialogPrimitive.Title + data-slot="alert-dialog-title" + className={cn( + "text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", + className, + )} + {...props} + /> + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { + return ( + <AlertDialogPrimitive.Description + data-slot="alert-dialog-description" + className={cn( + "text-xs/relaxed text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", + className, + )} + {...props} + /> + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps<typeof Button>) { + return ( + <Button + data-slot="alert-dialog-action" + className={cn(className)} + {...props} + /> + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: AlertDialogPrimitive.Close.Props & + Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { + return ( + <AlertDialogPrimitive.Close + data-slot="alert-dialog-cancel" + className={cn(className)} + render={<Button variant={variant} size={size} />} + {...props} + /> + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx index 7dee494..60ad9ca 100644 --- a/packages/ui/src/components/ui/button.tsx +++ b/packages/ui/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; 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 dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", { variants: { variant: { diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx index ab9fb71..226e302 100644 --- a/packages/ui/src/components/ui/field.tsx +++ b/packages/ui/src/components/ui/field.tsx @@ -9,7 +9,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { <fieldset data-slot="field-set" className={cn( - "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", + "flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className, )} {...props} @@ -40,7 +40,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { <div data-slot="field-group" className={cn( - "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col", + "group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4", className, )} {...props} @@ -49,15 +49,15 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { } const fieldVariants = cva( - "data-[invalid=true]:text-destructive gap-2 group/field flex w-full", + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", { 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", + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", responsive: - "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*: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", + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", }, }, defaultVariants: { @@ -72,7 +72,9 @@ function Field({ ...props }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { return ( + // biome-ignore lint/a11y/useSemanticElements: shadcn component <div + role="group" data-slot="field" data-orientation={orientation} className={cn(fieldVariants({ orientation }), className)} @@ -86,7 +88,7 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) { <div data-slot="field-content" className={cn( - "gap-0.5 group/field-content flex flex-1 flex-col leading-snug", + "group/field-content flex flex-1 flex-col gap-0.5 leading-snug", className, )} {...props} @@ -96,18 +98,26 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldLabel({ className, + required, + children, ...props -}: React.ComponentProps<typeof Label>) { +}: React.ComponentProps<typeof Label> & { + required?: boolean; +}) { return ( <Label data-slot="field-label" className={cn( - "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug", + "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col", className, )} + aria-required={!!required} {...props} - /> + > + {children} + {required && <span className="text-red-700 dark:text-red-500">*</span>} + </Label> ); } @@ -116,7 +126,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { <div data-slot="field-label" className={cn( - "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug", + "flex w-fit items-center gap-2 text-xs/relaxed leading-snug group-data-[disabled=true]/field:opacity-50", className, )} {...props} @@ -129,9 +139,9 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { <p data-slot="field-description" className={cn( - "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance", + "text-left text-xs/relaxed leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5", "last:mt-0 nth-last-2:-mt-1", - "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", className, )} {...props} @@ -151,7 +161,7 @@ function FieldSeparator({ data-slot="field-separator" data-content={!!children} className={cn( - "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative", + "relative -my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2", className, )} {...props} @@ -159,7 +169,7 @@ function FieldSeparator({ <Separator className="absolute inset-0 top-1/2" /> {children && ( <span - className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit" + className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground" data-slot="field-separator-content" > {children} @@ -197,11 +207,9 @@ function FieldError({ return ( <ul className="ml-4 flex list-disc flex-col gap-1"> {uniqueErrors.map( - (error, index) => + (error) => error?.message && ( - <li key={`${error.message.slice(6)}-${index}`}> - {error.message} - </li> + <li key={`field-error-${error.message}`}>{error.message}</li> ), )} </ul> @@ -216,7 +224,7 @@ function FieldError({ <div role="alert" data-slot="field-error" - className={cn("text-destructive text-xs font-normal", className)} + className={cn("text-xs font-normal text-destructive", className)} {...props} > {content} diff --git a/packages/ui/src/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx index 9a998c7..0d40c81 100644 --- a/packages/ui/src/components/ui/label.tsx +++ b/packages/ui/src/components/ui/label.tsx @@ -8,7 +8,7 @@ function Label({ className, ...props }: React.ComponentProps<"label">) { <label data-slot="label" className={cn( - "gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed", + "flex items-center gap-2 text-xs leading-none 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} diff --git a/packages/ui/src/components/ui/radio-group.tsx b/packages/ui/src/components/ui/radio-group.tsx index d8b39dd..df831e8 100644 --- a/packages/ui/src/components/ui/radio-group.tsx +++ b/packages/ui/src/components/ui/radio-group.tsx @@ -1,7 +1,7 @@ -import { Radio as RadioPrimitive } from "@base-ui/react/radio" -import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group" +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) { return ( @@ -10,7 +10,7 @@ function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) { className={cn("grid w-full gap-2", className)} {...props} /> - ) + ); } function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) { @@ -19,7 +19,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) { data-slot="radio-group-item" className={cn( "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3", - className + className, )} {...props} > @@ -30,7 +30,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) { <span className="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" /> </RadioPrimitive.Indicator> </RadioPrimitive.Root> - ) + ); } -export { RadioGroup, RadioGroupItem } +export { RadioGroup, RadioGroupItem }; |