diff --git a/.changeset/loud-shrimps-promise.md b/.changeset/loud-shrimps-promise.md
new file mode 100644
index 00000000000..1430880bda7
--- /dev/null
+++ b/.changeset/loud-shrimps-promise.md
@@ -0,0 +1,11 @@
+---
+'@shopify/polaris': minor
+---
+
+Updated `Tooltip` props and state management:
+
+- Added a new `open` prop
+- Added a new `defaultOpen` prop
+- Deprecated the `active` prop
+- Special cased the existing `active` prop behavior
+- Replaced `EphemeralPresenceManger` with alternative hysteresis pattern
diff --git a/polaris-react/src/components/Button/Button.stories.tsx b/polaris-react/src/components/Button/Button.stories.tsx
index 7936981bc86..594f9e167b1 100644
--- a/polaris-react/src/components/Button/Button.stories.tsx
+++ b/polaris-react/src/components/Button/Button.stories.tsx
@@ -11,6 +11,9 @@ import {
Box,
Popover,
ActionList,
+ Link,
+ Tooltip,
+ useCopyToClipboard,
} from '@shopify/polaris';
import {
PlusIcon,
@@ -19,6 +22,8 @@ import {
EditIcon,
MagicIcon,
DeleteIcon,
+ CheckIcon,
+ ClipboardIcon,
} from '@shopify/polaris-icons';
export default {
@@ -832,3 +837,33 @@ export function LoadingState() {
);
}
+
+export function CopyToClipboard() {
+ const [copy, status] = useCopyToClipboard({
+ defaultValue: 'hello@example.com',
+ });
+
+ return (
+
+
+
+ hello@example.com
+
+
+
+
+
+
+ );
+}
diff --git a/polaris-react/src/components/Tooltip/Tooltip.stories.tsx b/polaris-react/src/components/Tooltip/Tooltip.stories.tsx
index 173bf0a2fc3..4869f0b286d 100644
--- a/polaris-react/src/components/Tooltip/Tooltip.stories.tsx
+++ b/polaris-react/src/components/Tooltip/Tooltip.stories.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react';
+import React, {useCallback, useState} from 'react';
import {QuestionCircleIcon} from '@shopify/polaris-icons';
import type {ComponentMeta} from '@storybook/react';
import {
@@ -43,7 +43,7 @@ export function All() {
export function Default() {
return (
-
+
Order #1001
@@ -57,7 +57,7 @@ export function PreferredPosition() {
@@ -75,7 +75,7 @@ export function PreferredPosition() {
@@ -102,7 +102,7 @@ export function Width() {
@@ -119,7 +119,7 @@ export function Width() {
@@ -145,7 +145,7 @@ export function Padding() {
return (
-
+
Tooltip with
@@ -160,7 +160,7 @@ export function Padding() {
@@ -187,7 +187,7 @@ export function BorderRadius() {
@@ -204,7 +204,7 @@ export function BorderRadius() {
@@ -307,7 +307,7 @@ export function ActivatorAsDiv() {
return (
@@ -462,7 +462,7 @@ export function Alignment() {
export function HasUnderline() {
return (
-
+
Order #1001
@@ -486,6 +486,66 @@ export function PersistOnClick() {
);
}
+export function WithControlledState() {
+ const [open, setOpen] = useState(false);
+
+ const handleOpen = useCallback(() => {
+ setOpen(true);
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ return (
+
+
+
+ The tooltip is {String(open)}
+
+
+
+ );
+}
+
+export function WithUncontrolledState() {
+ return (
+
+
+
+
+ Default open true
+
+
+
+
+ Default open false
+
+
+
+
+ Default open undefined
+
+
+
+
+ );
+}
+
export function ActiveStates() {
const [popoverActive, setPopoverActive] = useState(false);
const [tooltipActive, setTooltipActive] =
@@ -558,7 +618,7 @@ export function ActiveStates() {
export function OneCharacter() {
return (
-
+
Order #1001
diff --git a/polaris-react/src/components/Tooltip/Tooltip.tsx b/polaris-react/src/components/Tooltip/Tooltip.tsx
index 635e3977fed..ea2a0ddbcea 100644
--- a/polaris-react/src/components/Tooltip/Tooltip.tsx
+++ b/polaris-react/src/components/Tooltip/Tooltip.tsx
@@ -5,14 +5,13 @@ import type {
} from '@shopify/polaris-tokens';
import {Portal} from '../Portal';
-import {useEphemeralPresenceManager} from '../../utilities/ephemeral-presence-manager';
import {findFirstFocusableNode} from '../../utilities/focus';
-import {useToggle} from '../../utilities/use-toggle';
import {classNames} from '../../utilities/css';
import {TooltipOverlay} from './components';
import type {TooltipOverlayProps} from './components';
import styles from './Tooltip.module.css';
+import {Timeout, useTimeout} from './utils';
export type Width = 'default' | 'wide';
export type Padding = 'default' | Extract;
@@ -23,7 +22,14 @@ export interface TooltipProps {
children?: React.ReactNode;
/** The content to display within the tooltip */
content: React.ReactNode;
- /** Toggle whether the tooltip is visible */
+ /** Toggle whether the tooltip is visible. */
+ open?: boolean;
+ /** Toggle whether the tooltip is visible initially */
+ defaultOpen?: boolean;
+ /**
+ * Toggle whether the tooltip is visible initially
+ * @deprecated Use `defaultOpen` instead
+ */
active?: boolean;
/** Delay in milliseconds while hovering over an element before the tooltip is visible */
hoverDelay?: number;
@@ -68,12 +74,25 @@ export interface TooltipProps {
onClose?(): void;
}
-const HOVER_OUT_TIMEOUT = 150;
+/**
+ * The [hysteresis](https://en.wikipedia.org/wiki/Hysteresis) flag is used to influence the `hoverDelay` and `animateOpen` behavior of the Tooltip.
+ * Adapted from the [MUI Tooltip component](https://github.com/mui/material-ui/blob/822a7e69c062a5e4f99f02b4a3aadc7fb51c2ce9/packages/mui-material/src/Tooltip/Tooltip.js#L217-L218)
+ */
+let hysteresisOpen = false;
+const hysteresisTimer = new Timeout();
+const HYSTERESIS_TIMEOUT = 150;
+
+export function testResetHysteresis() {
+ hysteresisOpen = false;
+ hysteresisTimer.clear();
+}
export function Tooltip({
children,
content,
dismissOnMouseOut,
+ open: openProp,
+ defaultOpen: defaultOpenProp,
active: originalActive,
hoverDelay,
preferredPosition = 'above',
@@ -84,148 +103,122 @@ export function Tooltip({
borderRadius: borderRadiusProp,
zIndexOverride,
hasUnderline,
- persistOnClick,
+ persistOnClick = false,
onOpen,
onClose,
}: TooltipProps) {
const borderRadius = borderRadiusProp || '200';
-
- const WrapperComponent: any = activatorWrapper;
- const {
- value: active,
- setTrue: setActiveTrue,
- setFalse: handleBlur,
- } = useToggle(Boolean(originalActive));
-
- const {value: persist, toggle: togglePersisting} = useToggle(
- Boolean(originalActive) && Boolean(persistOnClick),
+ const isControlled = typeof openProp === 'boolean';
+ const defaultOpen = defaultOpenProp ?? originalActive ?? false;
+ const animateOpen = useRef(!defaultOpen && !hysteresisOpen);
+ const [open, setOpen] = useState(defaultOpen);
+ const [isPersisting, setIsPersisting] = useState(
+ defaultOpen && persistOnClick,
);
- const [activatorNode, setActivatorNode] = useState(null);
- const {presenceList, addPresence, removePresence} =
- useEphemeralPresenceManager();
+ const isMouseEntered = useRef(false);
+ const hoverDelayTimer = useTimeout();
const id = useId();
+ const WrapperComponent: any = activatorWrapper;
const activatorContainer = useRef(null);
- const mouseEntered = useRef(false);
- const [shouldAnimate, setShouldAnimate] = useState(Boolean(!originalActive));
- const hoverDelayTimeout = useRef(null);
- const hoverOutTimeout = useRef(null);
-
- const handleFocus = useCallback(() => {
- if (originalActive !== false) {
- setActiveTrue();
- }
- }, [originalActive, setActiveTrue]);
+ const [activatorNode, setActivatorNode] = useState(null);
+ const wrapperClassNames = classNames(
+ WrapperComponent === 'div' && styles.TooltipContainer,
+ hasUnderline && styles.HasUnderline,
+ );
- useEffect(() => {
- const firstFocusable = activatorContainer.current
- ? findFirstFocusableNode(activatorContainer.current)
- : null;
- const accessibilityNode = firstFocusable || activatorContainer.current;
+ const handleOpen = useCallback(() => {
+ if (open) return;
- if (!accessibilityNode) return;
+ if (!isControlled && originalActive !== false) {
+ hysteresisTimer.clear();
- accessibilityNode.tabIndex = 0;
- accessibilityNode.setAttribute('aria-describedby', id);
- accessibilityNode.setAttribute('data-polaris-tooltip-activator', 'true');
- }, [id, children]);
+ animateOpen.current = !hysteresisOpen;
+ hysteresisOpen = true;
- useEffect(() => {
- return () => {
- if (hoverDelayTimeout.current) {
- clearTimeout(hoverDelayTimeout.current);
- }
- if (hoverOutTimeout.current) {
- clearTimeout(hoverOutTimeout.current);
- }
- };
- }, []);
+ setOpen(true);
+ }
- const handleOpen = useCallback(() => {
- setShouldAnimate(!presenceList.tooltip && !active);
onOpen?.();
- addPresence('tooltip');
- }, [addPresence, presenceList.tooltip, onOpen, active]);
+ }, [isControlled, onOpen, open, originalActive]);
const handleClose = useCallback(() => {
+ if (!open) return;
+
+ if (!isControlled) {
+ hysteresisTimer.start(HYSTERESIS_TIMEOUT, () => {
+ hysteresisOpen = false;
+ });
+
+ animateOpen.current = false;
+
+ setOpen(false);
+ }
+
onClose?.();
- setShouldAnimate(false);
- hoverOutTimeout.current = setTimeout(() => {
- removePresence('tooltip');
- }, HOVER_OUT_TIMEOUT);
- }, [removePresence, onClose]);
+ }, [open, isControlled, onClose]);
+
+ const handleMouseEnter = useCallback(() => {
+ // https://github.com/facebook/react/issues/10109
+ // Mouseenter event not triggered when cursor moves from disabled button
+ if (isMouseEntered.current) return;
+ isMouseEntered.current = true;
+
+ if (open) return;
+
+ if (hoverDelay && !hysteresisOpen) {
+ hoverDelayTimer.start(hoverDelay, () => {
+ handleOpen();
+ });
+ } else {
+ hoverDelayTimer.clear();
+ handleOpen();
+ }
+ }, [open, hoverDelayTimer, hoverDelay, handleOpen]);
+
+ const handleMouseLeave = useCallback(() => {
+ isMouseEntered.current = false;
+
+ hoverDelayTimer.clear();
+
+ if (isPersisting || !open) return;
+
+ handleClose();
+ }, [hoverDelayTimer, isPersisting, open, handleClose]);
+
+ const handleFocus = useCallback(() => {
+ if (open) return;
+
+ hoverDelayTimer.clear();
+
+ handleOpen();
+ }, [handleOpen, hoverDelayTimer, open]);
+
+ const handleBlur = useCallback(() => {
+ if (isPersisting) setIsPersisting(false);
+
+ handleClose();
+ }, [handleClose, isPersisting, setIsPersisting]);
const handleKeyUp = useCallback(
(event: React.KeyboardEvent) => {
if (event.key !== 'Escape') return;
- handleClose?.();
- handleBlur();
- persistOnClick && togglePersisting();
- },
- [handleBlur, handleClose, persistOnClick, togglePersisting],
- );
- useEffect(() => {
- if (originalActive === false && active) {
- handleClose();
- handleBlur();
- }
- }, [originalActive, active, handleClose, handleBlur]);
-
- const portal = activatorNode ? (
-
-
- {content}
-
-
- ) : null;
+ if (isPersisting) setIsPersisting(false);
- const wrapperClassNames = classNames(
- activatorWrapper === 'div' && styles.TooltipContainer,
- hasUnderline && styles.HasUnderline,
+ handleClose();
+ },
+ [handleClose, isPersisting, setIsPersisting],
);
- return (
- {
- handleOpen();
- handleFocus();
- }}
- onBlur={() => {
- handleClose();
- handleBlur();
-
- if (persistOnClick) {
- togglePersisting();
- }
- }}
- onMouseLeave={handleMouseLeave}
- onMouseOver={handleMouseEnterFix}
- onMouseDown={persistOnClick ? togglePersisting : undefined}
- ref={setActivator}
- onKeyUp={handleKeyUp}
- className={wrapperClassNames}
- >
- {children}
- {portal}
-
- );
+ const handleMouseDown = useCallback(() => {
+ if (!persistOnClick) return;
- function setActivator(node: HTMLElement | null) {
+ setIsPersisting((prevIsPersisting) => !prevIsPersisting);
+ }, [persistOnClick]);
+
+ const setActivator = useCallback((node: HTMLElement | null) => {
const activatorContainerRef: any = activatorContainer;
if (node == null) {
activatorContainerRef.current = null;
@@ -237,40 +230,87 @@ export function Tooltip({
setActivatorNode(node.firstElementChild);
activatorContainerRef.current = node;
- }
+ }, []);
- function handleMouseEnter() {
- mouseEntered.current = true;
- if (hoverDelay && !presenceList.tooltip) {
- hoverDelayTimeout.current = setTimeout(() => {
- handleOpen();
- handleFocus();
- }, hoverDelay);
+ // Sync controlled state with uncontrolled state
+ useEffect(() => {
+ if (!isControlled || openProp === open) return;
+
+ hoverDelayTimer.clear();
+
+ if (openProp && !originalActive) {
+ hysteresisTimer.clear();
+
+ animateOpen.current = !hysteresisOpen;
+ hysteresisOpen = true;
+
+ setOpen(true);
} else {
- handleOpen();
- handleFocus();
+ hysteresisTimer.start(HYSTERESIS_TIMEOUT, () => {
+ hysteresisOpen = false;
+ });
+
+ animateOpen.current = false;
+
+ setOpen(false);
}
- }
+ }, [hoverDelayTimer, isControlled, open, openProp, originalActive]);
- function handleMouseLeave() {
- if (hoverDelayTimeout.current) {
- clearTimeout(hoverDelayTimeout.current);
- hoverDelayTimeout.current = null;
+ // Note: Remove this effect along with the `active` prop in Polaris v14
+ useEffect(() => {
+ if (originalActive === false && open) {
+ handleClose();
}
+ }, [originalActive, handleClose, handleBlur, open]);
- mouseEntered.current = false;
- handleClose();
+ // Add `tabIndex` and other a11y attributes to the first focusable node
+ useEffect(() => {
+ const firstFocusable = activatorContainer.current
+ ? findFirstFocusableNode(activatorContainer.current)
+ : null;
+ const accessibilityNode = firstFocusable || activatorContainer.current;
- if (!persist) {
- handleBlur();
- }
- }
+ if (!accessibilityNode) return;
- // https://github.com/facebook/react/issues/10109
- // Mouseenter event not triggered when cursor moves from disabled button
- function handleMouseEnterFix() {
- !mouseEntered.current && handleMouseEnter();
- }
+ accessibilityNode.tabIndex = 0;
+ accessibilityNode.setAttribute('aria-describedby', id);
+ accessibilityNode.setAttribute('data-polaris-tooltip-activator', 'true');
+ }, [id, children]);
+
+ return (
+
+ {children}
+ {activatorNode && (
+
+
+ {content}
+
+
+ )}
+
+ );
}
function noop() {}
diff --git a/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx b/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx
index b17a4202214..42a4435d49a 100644
--- a/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx
+++ b/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx
@@ -2,10 +2,14 @@ import React from 'react';
import {mountWithApp} from 'tests/utilities';
import {Link} from '../../Link';
-import {Tooltip} from '../Tooltip';
+import {testResetHysteresis, Tooltip} from '../Tooltip';
import {TooltipOverlay} from '../components';
describe('', () => {
+ beforeEach(() => {
+ testResetHysteresis();
+ });
+
it('renders its children', () => {
const tooltip = mountWithApp(
@@ -35,16 +39,94 @@ describe('', () => {
expect(tooltipActive.find(TooltipOverlay)).toContainReactComponent('div');
});
+ it('renders initially when defaultOpen is true', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div');
+ });
+
it('does not render when active is false', () => {
- const tooltipActive = mountWithApp(
+ const tooltip = mountWithApp(
link content
,
);
- expect(tooltipActive.find(TooltipOverlay)).not.toContainReactComponent(
- 'div',
+ expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div');
+ });
+
+ it('does not render initially when defaultOpen is false', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div');
+ });
+
+ it('renders when open is true', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div');
+ });
+
+ it('does not render when open is false', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div');
+ });
+
+ it('renders when open is true and active is false', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div');
+ });
+
+ it('renders when open is true and defaultOpen is false', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div');
+ });
+
+ it('does not render when open is false and active is true', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div');
+ });
+
+ it('does not render when open is false and defaultOpen is true', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
);
+
+ expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div');
});
it('does not render when active prop is updated to false', () => {
@@ -61,9 +143,23 @@ describe('', () => {
expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div');
});
+ it('renders when defaultOpen prop is updated to false', () => {
+ const tooltip = mountWithApp(
+
+ link content
+ ,
+ );
+
+ findWrapperComponent(tooltip)!.trigger('onMouseOver');
+ expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div');
+
+ tooltip.setProps({defaultOpen: false});
+ expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div');
+ });
+
it('passes preventInteraction to TooltipOverlay when dismissOnMouseOut is true', () => {
const tooltip = mountWithApp(
-
+
link content
,
);
@@ -118,7 +214,7 @@ describe('', () => {
it('closes itself when escape is pressed on keyup', () => {
const tooltip = mountWithApp(
-
+
Order #1001
,
);
@@ -132,11 +228,11 @@ describe('', () => {
});
});
- it('does not call onOpen when initially activated', () => {
+ it('does not call onOpen initially when defaultOpen is true', () => {
const openSpy = jest.fn();
const tooltip = mountWithApp(
@@ -151,11 +247,11 @@ describe('', () => {
expect(openSpy).not.toHaveBeenCalled();
});
- it('calls onClose when initially activated and then closed', () => {
+ it('calls onClose initially when defaultOpen is true and then closed', () => {
const closeSpy = jest.fn();
const tooltip = mountWithApp(
@@ -216,7 +312,7 @@ describe('', () => {
const closeSpy = jest.fn();
const tooltip = mountWithApp(
-
+
link content
,
);
@@ -234,7 +330,7 @@ describe('', () => {
const closeSpy = jest.fn();
const tooltip = mountWithApp(
-
+
link content
,
);
@@ -255,7 +351,7 @@ describe('', () => {
link content
,
@@ -267,7 +363,7 @@ describe('', () => {
it("passes 'zIndexOverride' to TooltipOverlay", () => {
const tooltip = mountWithApp(
-
+
link content
,
);
diff --git a/polaris-react/src/components/Tooltip/utils.ts b/polaris-react/src/components/Tooltip/utils.ts
new file mode 100644
index 00000000000..5dcc971d675
--- /dev/null
+++ b/polaris-react/src/components/Tooltip/utils.ts
@@ -0,0 +1,70 @@
+import {useEffect, useRef} from 'react';
+
+/**
+ * Adapted from https://github.com/mui/material-ui/blob/0102a9579628d48d784511a562b7b72f0f51847e/packages/mui-utils/src/useTimeout/useTimeout.ts#L35
+ */
+export function useTimeout() {
+ const timeoutRef = useLazyRef(Timeout.create);
+
+ /* eslint-disable react-hooks/exhaustive-deps */
+ useEffect(timeoutRef.current.clearEffect, []);
+ /* eslint-enable react-hooks/exhaustive-deps */
+
+ return timeoutRef.current;
+}
+
+/**
+ * Adapted from https://github.com/mui/material-ui/blob/0102a9579628d48d784511a562b7b72f0f51847e/packages/mui-utils/src/useTimeout/useTimeout.ts#L5
+ */
+export class Timeout {
+ static create() {
+ return new Timeout();
+ }
+
+ id: ReturnType | null = null;
+
+ /**
+ * Executes `fn` after `delay`, clearing any previously scheduled call.
+ */
+ start = (delay: number, fn: () => void) => {
+ this.clear();
+
+ this.id = setTimeout(() => {
+ this.id = null;
+ fn();
+ }, delay);
+ };
+
+ clear = () => {
+ if (this.id === null) return;
+
+ clearTimeout(this.id);
+ this.id = null;
+ };
+
+ clearEffect = () => this.clear;
+}
+
+const uninitializedRef = {};
+
+/**
+ * A React.useRef() that is initialized lazily with a function. Note that it accepts an optional
+ * initialization argument, so the initialization function doesn't need to be an inline closure.
+ *
+ * Adapted from https://github.com/mui/material-ui/blob/0102a9579628d48d784511a562b7b72f0f51847e/packages/mui-utils/src/useLazyRef/useLazyRef.ts#L13
+ *
+ * @usage
+ * const lazyRef = useLazyRef(sortColumns, columns)
+ */
+export function useLazyRef(
+ init: (arg?: InitArg) => LazyRef,
+ initArg?: InitArg,
+) {
+ const lazyRef = useRef(uninitializedRef as unknown as LazyRef);
+
+ if (lazyRef.current === uninitializedRef) {
+ lazyRef.current = init(initArg);
+ }
+
+ return lazyRef;
+}
diff --git a/polaris-react/src/index.ts b/polaris-react/src/index.ts
index c9b223f2d72..060d9665206 100644
--- a/polaris-react/src/index.ts
+++ b/polaris-react/src/index.ts
@@ -420,6 +420,7 @@ export {
export {useFrame, FrameContext} from './utilities/frame';
export {ScrollLockManagerContext as _SECRET_INTERNAL_SCROLL_LOCK_MANAGER_CONTEXT} from './utilities/scroll-lock-manager';
export {WithinContentContext as _SECRET_INTERNAL_WITHIN_CONTENT_CONTEXT} from './utilities/within-content-context';
+export {useCopyToClipboard} from './utilities/use-copy-to-clipboard';
export {useEventListener} from './utilities/use-event-listener';
export {useTheme} from './utilities/use-theme';
export {useIndexResourceState} from './utilities/use-index-resource-state';
diff --git a/polaris-react/src/utilities/use-copy-to-clipboard.ts b/polaris-react/src/utilities/use-copy-to-clipboard.ts
new file mode 100644
index 00000000000..f66adceaa7d
--- /dev/null
+++ b/polaris-react/src/utilities/use-copy-to-clipboard.ts
@@ -0,0 +1,43 @@
+import React from 'react';
+
+type Status = 'inactive' | 'copied' | 'failed';
+
+interface UseCopyToClipboardOptions {
+ defaultValue?: string;
+ timeout?: number;
+}
+
+/**
+ * Copy text to the native clipboard using the `navigator.clipboard` API
+ * Adapted from https://www.benmvp.com/blog/copy-to-clipboard-react-custom-hook
+ */
+export function useCopyToClipboard(options: UseCopyToClipboardOptions = {}) {
+ const {defaultValue = '', timeout = 1500} = options;
+
+ const [status, setStatus] = React.useState('inactive');
+
+ const copy = React.useCallback(
+ (value?: string) => {
+ navigator.clipboard
+ .writeText(typeof value === 'string' ? value : defaultValue)
+ .then(
+ () => setStatus('copied'),
+ () => setStatus('failed'),
+ )
+ .catch((error) => {
+ throw error;
+ });
+ },
+ [defaultValue],
+ );
+
+ React.useEffect(() => {
+ if (status === 'inactive') return;
+
+ const timeoutId = setTimeout(() => setStatus('inactive'), timeout);
+
+ return () => clearTimeout(timeoutId);
+ }, [status, timeout]);
+
+ return [copy, status] as const;
+}