refactor: switch to ripple button

This commit is contained in:
zhom
2025-08-08 10:46:00 +04:00
parent 5c58b5c644
commit 1cb9ffa249
25 changed files with 344 additions and 82 deletions
+1
View File
@@ -46,6 +46,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"motion": "^12.23.12",
"next": "^15.4.6",
"next-themes": "^0.4.6",
"react": "^19.1.1",
+60
View File
@@ -74,6 +74,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
motion:
specifier: ^12.23.12
version: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next:
specifier: ^15.4.6
version: 15.4.6(@babel/core@7.28.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -1860,6 +1863,20 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
framer-motion@12.23.12:
resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -2343,6 +2360,26 @@ packages:
resolution: {integrity: sha512-DXO4L9W+08T+A7h5+xdT32l7IMot8z7WOH+7C1Maol571PnktQ8un7Ni4CyPFp4H+vht/FDA5/tpjRvWMFQDMw==}
engines: {node: '>=10', npm: '>=6'}
motion-dom@12.23.12:
resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
motion@12.23.12:
resolution: {integrity: sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4520,6 +4557,15 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
framer-motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
motion-dom: 12.23.12
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
fs-constants@1.0.0: {}
fs-minipass@2.1.0:
@@ -4981,6 +5027,20 @@ snapshots:
mmdb-lib@2.2.1: {}
motion-dom@12.23.12:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
framer-motion: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
tslib: 2.8.1
optionalDependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
ms@2.1.3: {}
nano-spawn@1.0.2: {}
+5 -4
View File
@@ -5,6 +5,7 @@ import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
import { RippleButton } from "./ui/ripple";
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
@@ -160,22 +161,22 @@ export function AppUpdateToast({
{!isUpdating && (
<div className="flex gap-2 items-center mt-3">
<Button
<RippleButton
onClick={() => void handleUpdateClick()}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="w-3 h-3" />
Update Now
</Button>
<Button
</RippleButton>
<RippleButton
variant="outline"
onClick={onDismiss}
size="sm"
className="text-xs"
>
Later
</Button>
</RippleButton>
</div>
)}
</div>
+11 -5
View File
@@ -12,6 +12,8 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
interface CamoufoxConfigDialogProps {
isOpen: boolean;
@@ -117,12 +119,16 @@ export function CamoufoxConfigDialog({
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={handleSave}
disabled={isSaving}
>
Save
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
+3 -2
View File
@@ -19,6 +19,7 @@ import { Label } from "@/components/ui/label";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, BrowserReleaseTypes } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ChangeVersionDialogProps {
isOpen: boolean;
@@ -288,9 +289,9 @@ export function ChangeVersionDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isUpdating}
onClick={() => {
+8 -3
View File
@@ -16,6 +16,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface CreateGroupDialogProps {
isOpen: boolean;
@@ -98,15 +99,19 @@ export function CreateGroupDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isCreating}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isCreating}
onClick={() => void handleCreate()}
disabled={!groupName.trim()}
>
Create Group
Create
</LoadingButton>
</DialogFooter>
</DialogContent>
+3 -2
View File
@@ -27,6 +27,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
| "mullvad-browser"
@@ -580,9 +581,9 @@ export function CreateProfileDialog({
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</Button>
</RippleButton>
<LoadingButton
onClick={handleCreate}
isLoading={isCreating}
+14 -8
View File
@@ -9,6 +9,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
interface DeleteConfirmationDialogProps {
isOpen: boolean;
@@ -59,16 +61,20 @@ export function DeleteConfirmationDialog({
)}
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void handleConfirm()}
<RippleButton
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{isLoading ? "Deleting..." : confirmButtonText}
</Button>
Cancel
</RippleButton>
<LoadingButton
variant="destructive"
onClick={() => void handleConfirm()}
isLoading={isLoading}
>
{confirmButtonText}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
+7 -2
View File
@@ -17,6 +17,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface DeleteGroupDialogProps {
isOpen: boolean;
@@ -188,9 +189,13 @@ export function DeleteGroupDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isDeleting}>
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isDeleting}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
variant="destructive"
isLoading={isDeleting}
+7 -2
View File
@@ -16,6 +16,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface EditGroupDialogProps {
isOpen: boolean;
@@ -108,9 +109,13 @@ export function EditGroupDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isUpdating}>
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isUpdating}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isUpdating}
onClick={() => void handleUpdate()}
+7 -2
View File
@@ -22,6 +22,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface GroupAssignmentDialogProps {
isOpen: boolean;
@@ -161,9 +162,13 @@ export function GroupAssignmentDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isAssigning}>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isAssigning}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isAssigning}
onClick={() => void handleAssign()}
+6 -5
View File
@@ -26,6 +26,7 @@ import {
TableRow,
} from "@/components/ui/table";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface GroupManagementDialogProps {
isOpen: boolean;
@@ -119,14 +120,14 @@ export function GroupManagementDialog({
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<Button
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create Group
</Button>
Create
</RippleButton>
</div>
{error && (
@@ -187,9 +188,9 @@ export function GroupManagementDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Close
</Button>
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+5 -5
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { RippleButton } from "./ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type Props = {
@@ -46,7 +47,6 @@ const HomeHeader = ({
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleLogoClick}
className="p-1 cursor-pointer"
title="Open donutbrowser.com"
>
@@ -59,7 +59,7 @@ const HomeHeader = ({
{selectedProfiles.length !== 1 ? "s" : ""} selected
</span>
<div className="flex gap-2">
<Button
<RippleButton
variant="outline"
size="sm"
onClick={onBulkGroupAssignment}
@@ -67,8 +67,8 @@ const HomeHeader = ({
>
<LuUsers className="w-4 h-4" />
Assign to Group
</Button>
<Button
</RippleButton>
<RippleButton
variant="destructive"
size="sm"
onClick={onBulkDelete}
@@ -76,7 +76,7 @@ const HomeHeader = ({
>
<LuTrash2 className="w-4 h-4" />
Delete Selected
</Button>
</RippleButton>
</div>
</div>
) : (
+10 -9
View File
@@ -26,6 +26,7 @@ import {
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ImportProfileDialogProps {
isOpen: boolean;
@@ -242,7 +243,7 @@ export function ImportProfileDialog({
);
if (profile) {
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
const defaultName = `Old ${browserName}`;
setAutoDetectProfileName(defaultName);
}
}
@@ -268,7 +269,7 @@ export function ImportProfileDialog({
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{/* Mode Selection */}
<div className="flex gap-2">
<Button
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
@@ -277,8 +278,8 @@ export function ImportProfileDialog({
disabled={isLoading}
>
Auto-Detect
</Button>
<Button
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
@@ -287,7 +288,7 @@ export function ImportProfileDialog({
disabled={isLoading}
>
Manual Import
</Button>
</RippleButton>
</div>
{/* Auto-Detect Mode */}
@@ -479,9 +480,9 @@ export function ImportProfileDialog({
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={handleClose}>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</Button>
</RippleButton>
{importMode === "auto-detect" ? (
<LoadingButton
isLoading={isImporting}
@@ -494,7 +495,7 @@ export function ImportProfileDialog({
isLoading
}
>
Import Profile
Import
</LoadingButton>
) : (
<LoadingButton
@@ -508,7 +509,7 @@ export function ImportProfileDialog({
!manualProfileName.trim()
}
>
Import Profile
Import
</LoadingButton>
)}
</DialogFooter>
+9 -2
View File
@@ -1,5 +1,8 @@
import { LuLoaderCircle } from "react-icons/lu";
import { type ButtonProps, Button as UIButton } from "./ui/button";
import {
type RippleButtonProps as ButtonProps,
RippleButton as UIButton,
} from "./ui/ripple";
type Props = ButtonProps & {
isLoading: boolean;
@@ -7,7 +10,11 @@ type Props = ButtonProps & {
};
export const LoadingButton = ({ isLoading, ...props }: Props) => {
return (
<UIButton className="grid place-items-center" {...props}>
<UIButton
className="grid place-items-center"
{...props}
disabled={props.disabled || isLoading}
>
{isLoading ? (
<LuLoaderCircle className="h-4 w-4 animate-spin" />
) : (
+3 -2
View File
@@ -15,6 +15,7 @@ import {
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
interface PermissionDialogProps {
isOpen: boolean;
@@ -148,9 +149,9 @@ export function PermissionDialog({
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
{isCurrentPermissionGranted ? "Done" : "Cancel"}
</Button>
</RippleButton>
{!isCurrentPermissionGranted && (
<LoadingButton
+8 -5
View File
@@ -55,6 +55,7 @@ import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { RippleButton } from "./ui/ripple";
interface ProfilesDataTableProps {
data: BrowserProfile[];
@@ -546,7 +547,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
<RippleButton
variant={isRunning ? "destructive" : "default"}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
@@ -565,7 +566,7 @@ export function ProfilesDataTable({
) : (
"Launch"
)}
</Button>
</RippleButton>
</span>
</TooltipTrigger>
{tooltipContent && (
@@ -959,15 +960,17 @@ export function ProfilesDataTable({
)}
</div>
<DialogFooter>
<Button
<RippleButton
variant="outline"
onClick={() => {
setProfileToRename(null);
}}
>
Cancel
</Button>
<Button onClick={() => void handleRename()}>Save</Button>
</RippleButton>
<RippleButton onClick={() => void handleRename()}>
Save
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+5 -4
View File
@@ -30,6 +30,7 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProfileSelectorDialogProps {
isOpen: boolean;
@@ -196,7 +197,7 @@ export function ProfileSelectorDialog({
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">Opening URL:</Label>
<Button
<RippleButton
variant="outline"
size="sm"
onClick={() => void handleCopyUrl()}
@@ -204,7 +205,7 @@ export function ProfileSelectorDialog({
>
<LuCopy className="w-3 h-3" />
Copy
</Button>
</RippleButton>
</div>
<div className="p-2 text-sm break-all rounded bg-muted">
{url}
@@ -312,9 +313,9 @@ export function ProfileSelectorDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
<RippleButton variant="outline" onClick={handleCancel}>
Cancel
</Button>
</RippleButton>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
+3 -2
View File
@@ -22,6 +22,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
name: string;
@@ -264,13 +265,13 @@ export function ProxyFormDialog({
</div>
<DialogFooter>
<Button
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isSubmitting}
onClick={handleSubmit}
+6 -5
View File
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/tooltip";
import { trimName } from "@/lib/name-utils";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyManagementDialogProps {
isOpen: boolean;
@@ -124,13 +125,13 @@ export function ProxyManagementDialog({
profiles
</p>
</div>
<Button
<RippleButton
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create Proxy
</Button>
</RippleButton>
</div>
{/* Proxy List - Scrollable */}
@@ -150,10 +151,10 @@ export function ProxyManagementDialog({
<p className="mb-4 text-sm text-muted-foreground">
Create your first proxy configuration to get started
</p>
<Button variant="outline" onClick={handleCreateProxy}>
<RippleButton variant="outline" onClick={handleCreateProxy}>
<FiPlus className="mr-2 w-4 h-4" />
Create First Proxy
</Button>
</RippleButton>
</div>
) : (
<div className="overflow-y-auto pr-2 space-y-2 h-full">
@@ -221,7 +222,7 @@ export function ProxyManagementDialog({
</div>
<DialogFooter className="flex-shrink-0">
<Button onClick={onClose}>Close</Button>
<RippleButton onClick={onClose}>Close</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+8 -7
View File
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxySettingsDialogProps {
isOpen: boolean;
@@ -142,15 +143,15 @@ export function ProxySettingsDialog({
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
<RippleButton
variant="outline"
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create New
</Button>
Create
</RippleButton>
</TooltipTrigger>
<TooltipContent>
<p>Create a new proxy configuration</p>
@@ -263,12 +264,12 @@ export function ProxySettingsDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanged()}>
</RippleButton>
<RippleButton onClick={handleSave} disabled={!hasChanged()}>
Save
</Button>
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+3 -2
View File
@@ -19,6 +19,7 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { BrowserReleaseTypes } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ReleaseTypeSelectorProps {
selectedReleaseType: "stable" | "nightly" | null;
@@ -85,7 +86,7 @@ export function ReleaseTypeSelector({
{showDropdown ? (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen} modal={true}>
<PopoverTrigger asChild>
<Button
<RippleButton
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
@@ -93,7 +94,7 @@ export function ReleaseTypeSelector({
>
{selectedDisplayText}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</RippleButton>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
+3 -2
View File
@@ -33,6 +33,7 @@ import {
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
set_as_default_browser: boolean;
@@ -529,9 +530,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
+3 -2
View File
@@ -18,6 +18,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { RippleButton } from "./ripple";
interface ComboboxOption {
value: string;
@@ -47,7 +48,7 @@ export function Combobox({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
<RippleButton
variant="outline"
role="combobox"
aria-expanded={open}
@@ -57,7 +58,7 @@ export function Combobox({
? options.find((option) => option.value === value)?.label
: placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</RippleButton>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { type HTMLMotionProps, motion, type Transition } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"relative overflow-hidden cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
},
size: {
default: "h-10 px-4 py-2 has-[>svg]:px-3",
sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-11 px-8 has-[>svg]:px-6",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const rippleVariants = cva("absolute rounded-full size-5 pointer-events-none", {
variants: {
variant: {
default: "bg-primary-foreground",
destructive: "bg-destructive",
outline: "bg-input",
secondary: "bg-secondary",
ghost: "bg-accent",
},
},
defaultVariants: {
variant: "default",
},
});
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonProps = HTMLMotionProps<"button"> & {
children: React.ReactNode;
rippleClassName?: string;
scale?: number;
transition?: Transition;
} & VariantProps<typeof buttonVariants>;
function RippleButton({
ref,
children,
onClick,
className,
rippleClassName,
variant,
size,
scale = 10,
transition = { duration: 0.6, ease: "easeOut" },
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
return (
<motion.button
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{children}
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
className={cn(
rippleVariants({ variant, className: rippleClassName }),
)}
style={{
top: ripple.y - 10,
left: ripple.x - 10,
}}
/>
))}
</motion.button>
);
}
export { RippleButton, type RippleButtonProps };