Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,27 +1,8 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { ReactNode, useCallback, useId, useMemo, useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { ClipboardContext } from "@/hooks/useCopy";
import BaseDialog from "./BaseDialog";

// ─── Context ──────────────────────────────────────────────────────────────────

interface ClipboardContextValue {
triggerWarning: () => void;
}

const ClipboardContext = createContext<ClipboardContextValue | null>(null);

// ─── Provider ─────────────────────────────────────────────────────────────────

/**
* Mounts a single clipboard-warning dialog for the whole app.
* Wrap the app root with this once; every `useCopy()` call shares it.
Expand Down Expand Up @@ -81,59 +62,3 @@ export function ClipboardProvider({ children }: { children: ReactNode }) {
</ClipboardContext.Provider>
);
}

// ─── Hook ─────────────────────────────────────────────────────────────────────

export interface UseCopyResult {
/** Call with the text to copy. Shows the warning dialog when clipboard access
* is unavailable (insecure context or API error). */
copy: (text: string) => void;
/** True for 1500 ms after a successful copy. Use for inline visual feedback. */
copied: boolean;
}

/**
* Safe clipboard copy with automatic insecure-context handling.
*
* Must be used within `<ClipboardProvider>`.
*
* ```tsx
* const { copy, copied } = useCopy();
* <button onClick={() => copy(deviceId)}>{copied ? "Copied!" : "Copy"}</button>
* ```
*/
export function useCopy(): UseCopyResult {
const ctx = useContext(ClipboardContext);
if (!ctx) throw new Error("useCopy must be used within <ClipboardProvider>");

const { triggerWarning } = ctx;
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const copy = useCallback(
(text: string) => {
if (!globalThis.isSecureContext) {
triggerWarning();
return;
}

navigator.clipboard.writeText(text).then(
() => {
if (timerRef.current) clearTimeout(timerRef.current);
setCopied(true);
timerRef.current = setTimeout(() => setCopied(false), 1500);
},
() => triggerWarning(),
);
},
[triggerWarning],
);

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

return { copy, copied };
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CheckIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { useCopy } from "./ClipboardProvider";
import { useCopy } from "@/hooks/useCopy";

const sizes = {
sm: { button: "p-1 rounded", icon: "w-3.5 h-3.5" },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { useCopy } from "./ClipboardProvider";
import { useCopy } from "@/hooks/useCopy";

export interface CopyWarningRenderProps {
/** Triggers a clipboard copy. Shows the warning dialog if clipboard access
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ vi.mock("@/hooks/useFocusTrap", () => ({
useFocusTrap: vi.fn(),
}));

import { ClipboardProvider, useCopy } from "../ClipboardProvider";
import { ClipboardProvider } from "../ClipboardProvider";
import { useCopy } from "@/hooks/useCopy";

// ─── clipboard setup ──────────────────────────────────────────────────────────

Expand Down
72 changes: 72 additions & 0 deletions ui-react/apps/console/src/hooks/useCopy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";

// ─── Context ──────────────────────────────────────────────────────────────────

export interface ClipboardContextValue {
triggerWarning: () => void;
}

export const ClipboardContext = createContext<ClipboardContextValue | null>(null);

// ─── Hook ─────────────────────────────────────────────────────────────────────

interface UseCopyResult {
/** Call with the text to copy. Shows the warning dialog when clipboard access
* is unavailable (insecure context or API error). */
copy: (text: string) => void;
/** True for 1500 ms after a successful copy. Use for inline visual feedback. */
copied: boolean;
}

/**
* Safe clipboard copy with automatic insecure-context handling.
*
* Must be used within `<ClipboardProvider>`.
*
* ```tsx
* const { copy, copied } = useCopy();
* <button onClick={() => copy(deviceId)}>{copied ? "Copied!" : "Copy"}</button>
* ```
*/
export function useCopy(): UseCopyResult {
const ctx = useContext(ClipboardContext);
if (!ctx) throw new Error("useCopy must be used within <ClipboardProvider>");

const { triggerWarning } = ctx;
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const copy = useCallback(
(text: string) => {
if (!globalThis.isSecureContext) {
triggerWarning();
return;
}

navigator.clipboard.writeText(text).then(
() => {
if (timerRef.current) clearTimeout(timerRef.current);
setCopied(true);
timerRef.current = setTimeout(() => setCopied(false), 1500);
},
() => triggerWarning(),
);
},
[triggerWarning],
);

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

return { copy, copied };
}
Loading