Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/components/shared/share-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { ShareButton } from "@/components/shared/share-button";

describe("ShareButton", () => {
let writeTextMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: writeTextMock,
},
writable: true,
configurable: true,
});
});

it("renders the share button trigger", () => {
render(<ShareButton url="https://example.com/share-test" />);
expect(screen.getByRole("button", { name: /share/i })).toBeInTheDocument();
});

it("opens modal and handles copy and timeout cleanup", async () => {
render(<ShareButton url="https://example.com/share-test" />);

const shareBtn = screen.getByRole("button", { name: /share/i });
fireEvent.click(shareBtn);

const copyBtn = screen.getByRole("button", { name: /copy/i });
expect(copyBtn).toBeInTheDocument();

await act(async () => {
fireEvent.click(copyBtn);
});

expect(writeTextMock).toHaveBeenCalledWith("https://example.com/share-test");
});

it("clears timeout on component unmount", async () => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const { unmount } = render(<ShareButton url="https://example.com/unmount-test" />);

fireEvent.click(screen.getByRole("button", { name: /share/i }));
const copyBtn = screen.getByRole("button", { name: /copy/i });

await act(async () => {
fireEvent.click(copyBtn);
});

unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
36 changes: 31 additions & 5 deletions src/components/shared/share-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Share2, Copy, Twitter, Linkedin, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Expand Down Expand Up @@ -31,13 +31,33 @@ export function ShareButton({
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [canShare, setCanShare] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (typeof navigator !== 'undefined' && navigator.share) {
setCanShare(true);
}
}, []);

useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);

const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = null;
}
setCopied(false);
}
};

const handleShareClick = async () => {
if (canShare) {
try {
Expand All @@ -50,19 +70,25 @@ export function ShareButton({
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Error sharing', err);
setOpen(true);
handleOpenChange(true);
}
}
} else {
setOpen(true);
handleOpenChange(true);
}
};

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
copyTimeoutRef.current = setTimeout(() => {
setCopied(false);
copyTimeoutRef.current = null;
}, 2000);
} catch (err) {
console.error('Failed to copy', err);
}
Expand All @@ -85,7 +111,7 @@ export function ShareButton({
Share
</Button>

<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Share link</DialogTitle>
Expand Down