Skip to content

Commit

Permalink
Update Copy to Clipboard recipe to show/hide on hover/focus events (#…
Browse files Browse the repository at this point in the history
…11802)

### WHAT is this pull request doing?

This PR updates the Copy to Clipboard recipe to show/hide the copy
button on hover/focus events. This was accomplished by adding a few more
hooks to the system:
  - `useHover` - Tracks `mouseenter`/`mouseleave` events on a target ref
  - `useFocus` - Tracks `focus`/`blur` events on a target ref
  - `useFocusIn` - Tracks `focusin`/`focusout` events on a target ref
- `useMediaQuery` - Tracks `change` events on a target
`matchMedia(query)`. Additionally, supports `queryAliases` to streamline
the application of [common media
queries](https://github.com/argyleink/open-props/blob/09e70c03c0a2533d06ec823f47490f018eb27f23/src/props.media.css#L21-L24)
e.g.
    `const isMouseDevice = useMediaQuery('mouse')`

Example: Show the copy button on `mouseenter` of the target ref:


https://github.com/Shopify/polaris/assets/32409546/b3799bdf-e915-4761-a68c-f2724cccd9f1

Example: Always show the copy button on non-mouse devices


https://github.com/Shopify/polaris/assets/32409546/221d7c76-9a4a-49e2-8be3-8f97d1d7a3b2

Example: Show the copy button on `focusin` of the target ref


https://github.com/Shopify/polaris/assets/32409546/49c078f0-158a-40b3-8494-c15d94f3d0ea

---------

Co-authored-by: Lo Kim <lo.kim@shopify.com>
Co-authored-by: Sam Rose <11774595+sam-b-rose@users.noreply.github.com>
  • Loading branch information
3 people committed Apr 1, 2024
1 parent cbb28ec commit c3b6ffe
Show file tree
Hide file tree
Showing 9 changed files with 544 additions and 17 deletions.
5 changes: 5 additions & 0 deletions .changeset/famous-tigers-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris': minor
---

Added `useHover`, `useFocus`, `useFocusIn`, and `useMediaQuery` hooks for building Copy to Clipboard actions
57 changes: 40 additions & 17 deletions polaris-react/src/components/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type {ComponentMeta} from '@storybook/react';
import type {TextProps} from '@shopify/polaris';
import {
useCopyToClipboard,
useFocusIn,
useHover,
useMediaQuery,
Link,
Tooltip,
Button,
Expand Down Expand Up @@ -844,26 +847,46 @@ export function CopyToClipboard() {
defaultValue: 'hello@example.com',
});

const ref = useRef(null);
const isFocusedIn = useFocusIn(ref);
const isHovered = useHover(ref);
const isMouseDevice = useMediaQuery('mouse');
const isMouseHovered = isMouseDevice ? isHovered : true;

return (
<div style={{maxWidth: 300, paddingTop: 100}}>
<Card>
<InlineStack align="space-between" gap="200">
<Link removeUnderline>hello@example.com</Link>
<Tooltip
dismissOnMouseOut
hoverDelay={500}
preferredPosition="above"
content="Copy"
active={status === 'copied' ? false : undefined}
>
<Button
variant="tertiary"
accessibilityLabel="Copy email address"
icon={status === 'copied' ? CheckIcon : ClipboardIcon}
onClick={copy}
/>
</Tooltip>
</InlineStack>
<div ref={ref}>
<InlineStack align="space-between" gap="200" blockAlign="center">
<Link removeUnderline>hello@example.com</Link>
<div
style={{
opacity:
isMouseHovered || isFocusedIn || status === 'copied' ? 1 : 0,
transition:
isMouseHovered || isFocusedIn
? 'var(--p-motion-duration-100) var(--p-motion-ease) opacity'
: 'none',
}}
>
<Tooltip
dismissOnMouseOut
hoverDelay={500}
preferredPosition="above"
content="Copy"
active={status === 'copied' ? false : undefined}
activatorWrapper="div"
>
<Button
variant="tertiary"
accessibilityLabel="Copy email address"
onClick={copy}
icon={status === 'copied' ? CheckIcon : ClipboardIcon}
/>
</Tooltip>
</div>
</InlineStack>
</div>
</Card>
</div>
);
Expand Down
3 changes: 3 additions & 0 deletions polaris-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,9 @@ export {ScrollLockManagerContext as _SECRET_INTERNAL_SCROLL_LOCK_MANAGER_CONTEXT
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 {useFocus, useFocusIn} from './utilities/use-focus';
export {useHover} from './utilities/use-hover';
export {useMediaQuery} from './utilities/use-media-query';
export {useTheme} from './utilities/use-theme';
export {useIndexResourceState} from './utilities/use-index-resource-state';
export {
Expand Down
141 changes: 141 additions & 0 deletions polaris-react/src/utilities/tests/use-focus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, {useRef} from 'react';
import {mount} from 'tests/utilities';

import {useFocus, useFocusIn} from '../use-focus';

describe('useFocus', () => {
it('returns false by default', () => {
function App() {
const ref = useRef(null);
const isFocused = useFocus(ref);
return <div ref={ref}>{String(isFocused)}</div>;
}

const app = mount(<App />);

expect(app).toContainReactText('false');
});

it('returns true on focus', () => {
function App() {
const ref = useRef(null);
const isFocused = useFocus(ref);
return <div ref={ref}>{String(isFocused)}</div>;
}

const app = mount(<App />);
const div = app.find('div')!.domNode!;

app.act(() => {
div.dispatchEvent(new Event('focus'));
});

expect(app).toContainReactText('true');
});

it('returns false on focus and blur', () => {
function App() {
const ref = useRef(null);
const isFocused = useFocus(ref);
return <div ref={ref}>{String(isFocused)}</div>;
}

const app = mount(<App />);
const div = app.find('div')!.domNode!;

app.act(() => {
div.dispatchEvent(new Event('focus'));
div.dispatchEvent(new Event('blur'));
});

expect(app).toContainReactText('false');
});
});

describe('useFocusIn', () => {
it('returns false by default', () => {
function App() {
const ref = useRef(null);
const isFocusedIn = useFocusIn(ref);
return <div ref={ref}>{String(isFocusedIn)}</div>;
}

const app = mount(<App />);

expect(app).toContainReactText('false');
});

it('returns true on focusin', () => {
function App() {
const ref = useRef(null);
const isFocusedIn = useFocusIn(ref);
return <div ref={ref}>{String(isFocusedIn)}</div>;
}

const app = mount(<App />);
const div = app.find('div')!.domNode!;

app.act(() => {
div.dispatchEvent(new Event('focusin'));
});

expect(app).toContainReactText('true');
});

it('returns false on focusin and focusout', () => {
jest.useFakeTimers();

function App() {
const ref = useRef(null);
const isFocusedIn = useFocusIn(ref);
return <div ref={ref}>{String(isFocusedIn)}</div>;
}

const app = mount(<App />);
const div = app.find('div')!.domNode!;

app.act(() => {
div.dispatchEvent(new Event('focusin'));
div.dispatchEvent(new Event('focusout'));
});

// Remains true until the next turn of the event loop (See next test)
expect(app).toContainReactText('true');

app.act(() => jest.advanceTimersByTime(1));

// Updates to false on the next turn of the event loop
expect(app).toContainReactText('false');
});

it('returns true on focusin between children', () => {
jest.useFakeTimers();

function App() {
const ref = useRef(null);
const isFocusedIn = useFocusIn(ref);
return <div ref={ref}>{String(isFocusedIn)}</div>;
}

const app = mount(<App />);
const div = app.find('div')!.domNode!;

app.act(() => {
div.dispatchEvent(new Event('focusin'));
div.dispatchEvent(new Event('focusout'));
});

// Remains true until the next turn of the event loop
expect(app).toContainReactText('true');

// Subsequent focusin events clear the deferred focusout
app.act(() => {
div.dispatchEvent(new Event('focusin'));
});

app.act(() => jest.advanceTimersByTime(1));

// Remains true on the next turn of the event loop
expect(app).toContainReactText('true');
});
});
53 changes: 53 additions & 0 deletions polaris-react/src/utilities/tests/use-hover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, {useRef} from 'react';
import {mount} from 'tests/utilities';

import {useHover} from '../use-hover';

describe('useHover', () => {
it('returns false by default', () => {
function App() {
const ref = useRef(null);
const isHovered = useHover(ref);
return <div ref={ref}>{String(isHovered)}</div>;
}

const app = mount(<App />);

expect(app).toContainReactText('false');
});

it('returns true on mouseenter', () => {
function App() {
const ref = useRef(null);
const isHovered = useHover(ref);
return <div ref={ref}>{String(isHovered)}</div>;
}

const app = mount(<App />);
const div = app.find('div')!.domNode!;

app.act(() => {
div.dispatchEvent(new Event('mouseenter'));
});

expect(app).toContainReactText('true');
});

it('returns false on mouseenter and mouseleave', () => {
function App() {
const ref = useRef(null);
const isHovered = useHover(ref);
return <div ref={ref}>{String(isHovered)}</div>;
}

const app = mount(<App />);
const div = app.find('div')!.domNode!;

app.act(() => {
div.dispatchEvent(new Event('mouseenter'));
div.dispatchEvent(new Event('mouseleave'));
});

expect(app).toContainReactText('false');
});
});
Loading

0 comments on commit c3b6ffe

Please sign in to comment.