Skip to content

Commit

Permalink
feat(components): add clipboard (#2189)
Browse files Browse the repository at this point in the history
  • Loading branch information
cschroeter authored Feb 14, 2024
1 parent 2ab039a commit 6b47df9
Show file tree
Hide file tree
Showing 63 changed files with 1,082 additions and 2 deletions.
10 changes: 10 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createContext } from '../create-context'
import { type UseClipboardReturn } from './use-clipboard'

export interface ClipboardContext extends UseClipboardReturn {}

export const [ClipboardProvider, useClipboardContext] = createContext<ClipboardContext>({
name: 'ClipboardContext',
hookName: 'useClipboardContext',
providerName: '<ClipboardProvider />',
})
15 changes: 15 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { mergeProps } from '@zag-js/react'
import { forwardRef } from 'react'
import { ark, type HTMLArkProps } from '../factory'
import { useClipboardContext } from './clipboard-context'

export interface ClipboardControlProps extends HTMLArkProps<'div'> {}

export const ClipboardControl = forwardRef<HTMLDivElement, ClipboardControlProps>((props, ref) => {
const api = useClipboardContext()
const mergedProps = mergeProps(api.controlProps, props)

return <ark.div {...mergedProps} ref={ref} />
})

ClipboardControl.displayName = 'ClipboardControl'
24 changes: 24 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { mergeProps } from '@zag-js/react'
import { forwardRef, type ReactNode } from 'react'
import { ark, type HTMLArkProps } from '../factory'
import { useClipboardContext } from './clipboard-context'

export interface ClipboardIndicatorProps extends HTMLArkProps<'div'> {
copied?: ReactNode
}

export const ClipboardIndicator = forwardRef<HTMLDivElement, ClipboardIndicatorProps>(
(props, ref) => {
const { children, copied, ...localProps } = props
const api = useClipboardContext()
const mergedProps = mergeProps(api.getIndicatorProps({ copied: api.isCopied }), localProps)

return (
<ark.div {...mergedProps} ref={ref}>
{api.isCopied ? copied : children}
</ark.div>
)
},
)

ClipboardIndicator.displayName = 'Clipb oardIndicator'
15 changes: 15 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { mergeProps } from '@zag-js/react'
import { forwardRef } from 'react'
import { ark, type HTMLArkProps } from '../factory'
import { useClipboardContext } from './clipboard-context'

export interface ClipboardInputProps extends HTMLArkProps<'input'> {}

export const ClipboardInput = forwardRef<HTMLInputElement, ClipboardInputProps>((props, ref) => {
const api = useClipboardContext()
const mergedProps = mergeProps(api.inputProps, props)

return <ark.input {...mergedProps} ref={ref} />
})

ClipboardInput.displayName = 'ClipboardInput'
15 changes: 15 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { mergeProps } from '@zag-js/react'
import { forwardRef } from 'react'
import { ark, type HTMLArkProps } from '../factory'
import { useClipboardContext } from './clipboard-context'

export interface ClipboardLabelProps extends HTMLArkProps<'label'> {}

export const ClipboardLabel = forwardRef<HTMLLabelElement, ClipboardLabelProps>((props, ref) => {
const api = useClipboardContext()
const mergedProps = mergeProps(api.labelProps, props)

return <ark.label {...mergedProps} ref={ref} />
})

ClipboardLabel.displayName = 'ClipboardLabel'
37 changes: 37 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard-root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { mergeProps } from '@zag-js/react'
import { forwardRef, type ReactNode } from 'react'
import { createSplitProps } from '../create-split-props'
import { ark, type HTMLArkProps } from '../factory'
import { runIfFn } from '../run-if-fn'
import type { Assign } from '../types'
import { ClipboardProvider } from './clipboard-context'
import { useClipboard, type UseClipboardProps, type UseClipboardReturn } from './use-clipboard'

export interface ClipboardRootProps
extends Assign<
HTMLArkProps<'div'>,
{
children?: ReactNode | ((api: UseClipboardReturn) => ReactNode)
}
>,
UseClipboardProps {}

export const ClipboardRoot = forwardRef<HTMLDivElement, ClipboardRootProps>((props, ref) => {
const [useClipboardProps, { children, ...localProps }] = createSplitProps<UseClipboardProps>()(
props,
['getRootNode', 'id', 'ids', 'value', 'timeout', 'onCopyStatusChange'],
)
const api = useClipboard(useClipboardProps)
const mergedProps = mergeProps(api.rootProps, localProps)
const view = runIfFn(children, api)

return (
<ClipboardProvider value={api}>
<ark.div ref={ref} {...mergedProps}>
{view}
</ark.div>
</ClipboardProvider>
)
})

ClipboardRoot.displayName = 'ClipboardRoot'
17 changes: 17 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard-trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { mergeProps } from '@zag-js/react'
import { forwardRef } from 'react'
import { ark, type HTMLArkProps } from '../factory'
import { useClipboardContext } from './clipboard-context'

export interface ClipboardTriggerProps extends HTMLArkProps<'button'> {}

export const ClipboardTrigger = forwardRef<HTMLButtonElement, ClipboardTriggerProps>(
(props, ref) => {
const api = useClipboardContext()
const mergedProps = mergeProps(api.triggerProps, props)

return <ark.button {...mergedProps} ref={ref} />
},
)

ClipboardTrigger.displayName = 'ClipboardTrigger'
8 changes: 8 additions & 0 deletions packages/frameworks/react/src/clipboard/clipboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ClipboardControl as Control } from './clipboard-control'
import { ClipboardIndicator as Indicator } from './clipboard-indicator'
import { ClipboardInput as Input } from './clipboard-input'
import { ClipboardLabel as Label } from './clipboard-label'
import { ClipboardRoot as Root } from './clipboard-root'
import { ClipboardTrigger as Trigger } from './clipboard-trigger'

export { Control, Indicator, Input, Label, Root, Trigger }
31 changes: 31 additions & 0 deletions packages/frameworks/react/src/clipboard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type CopyStatusDetails as ClipboardCopyStatusDetails } from '@zag-js/clipboard'
import { useClipboardContext, type ClipboardContext } from './clipboard-context'
import { ClipboardControl, type ClipboardControlProps } from './clipboard-control'
import { ClipboardIndicator, type ClipboardIndicatorProps } from './clipboard-indicator'
import { ClipboardInput, type ClipboardInputProps } from './clipboard-input'
import { ClipboardLabel, type ClipboardLabelProps } from './clipboard-label'
import { ClipboardRoot, type ClipboardRootProps } from './clipboard-root'
import { ClipboardTrigger, type ClipboardTriggerProps } from './clipboard-trigger'

export * as Clipboard from './clipboard'

export {
ClipboardControl,
ClipboardIndicator,
ClipboardInput,
ClipboardLabel,
ClipboardRoot,
ClipboardTrigger,
useClipboardContext,
}

export type {
ClipboardContext,
ClipboardControlProps,
ClipboardCopyStatusDetails,
ClipboardIndicatorProps,
ClipboardInputProps,
ClipboardLabelProps,
ClipboardRootProps,
ClipboardTriggerProps,
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Meta } from '@storybook/react'
import { Clipboard } from '../'
import './clipbard.css'
import { CheckIcon, ClipboardCopyIcon } from './icons'

const meta: Meta = {
title: 'Components / Clipboard',
}

export default meta

export const Basic = () => {
return (
<Clipboard.Root value="https://ark-ui.com">
<Clipboard.Label>Copy this link</Clipboard.Label>
<Clipboard.Control>
<Clipboard.Input />
<Clipboard.Trigger>
<Clipboard.Indicator copied={<CheckIcon />}>
<ClipboardCopyIcon />
</Clipboard.Indicator>
</Clipboard.Trigger>
</Clipboard.Control>
</Clipboard.Root>
)
}

export const RenderFn = () => {
return (
<Clipboard.Root value="https://ark-ui.com">
{(api) => (
<>
<Clipboard.Label>Copy this link</Clipboard.Label>
<Clipboard.Control>
<Clipboard.Input />
<Clipboard.Trigger>
{api.isCopied ? <CheckIcon /> : <ClipboardCopyIcon />}
</Clipboard.Trigger>
</Clipboard.Control>
</>
)}
</Clipboard.Root>
)
}
37 changes: 37 additions & 0 deletions packages/frameworks/react/src/clipboard/stories/icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const ClipboardCopyIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
<path d="M16 4h2a2 2 0 0 1 2 2v4" />
<path d="M21 14H11" />
<path d="m15 10-4 4 4 4" />
</svg>
)

const CheckIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
)

export { CheckIcon, ClipboardCopyIcon }
51 changes: 51 additions & 0 deletions packages/frameworks/react/src/clipboard/tests/clipboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { clipboardAnatomy } from '@ark-ui/anatomy'
// eslint-disable-next-line testing-library/no-manual-cleanup
import { cleanup, render, screen } from '@testing-library/react/pure'
import user from '@testing-library/user-event'
import { Clipboard } from '../'
import { getExports, getParts } from '../../setup-test'
import { CheckIcon, ClipboardCopyIcon } from '../stories/icons'

const ComponentUnderTest = () => (
<Clipboard.Root value="https://ark-ui.com">
<Clipboard.Label>Copy this link</Clipboard.Label>
<Clipboard.Control>
<Clipboard.Input />
<Clipboard.Trigger>
<Clipboard.Indicator copied={<CheckIcon />}>
<ClipboardCopyIcon />
</Clipboard.Indicator>
</Clipboard.Trigger>
</Clipboard.Control>
</Clipboard.Root>
)

describe('Checkbox / Parts & Exports', () => {
afterAll(() => {
cleanup()
})

render(<ComponentUnderTest />)

it.each(getParts(clipboardAnatomy))('should render part %s', async (part) => {
// eslint-disable-next-line testing-library/no-node-access
expect(document.querySelector(part)).toBeInTheDocument()
})

it.each(getExports(clipboardAnatomy))('should export %s', async (part) => {
expect(Clipboard[part]).toBeDefined()
})
})

describe('Clipboard', () => {
afterEach(() => {
cleanup()
})

it('should copy the value into the clipboard', async () => {
render(<ComponentUnderTest />)

await user.click(screen.getByRole('button', { name: 'Copy to clipboard' }))
expect(window.navigator.clipboard.writeText).toHaveBeenCalledWith('https://ark-ui.com')
})
})
24 changes: 24 additions & 0 deletions packages/frameworks/react/src/clipboard/use-clipboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as clipboard from '@zag-js/clipboard'
import { normalizeProps, useMachine, type PropTypes } from '@zag-js/react'
import { useId } from 'react'
import { useEnvironmentContext } from '../environment'
import { type Optional } from '../types'

export interface UseClipboardProps extends Optional<clipboard.Context, 'id'> {}
export interface UseClipboardReturn extends clipboard.Api<PropTypes> {}

export const useClipboard = (props: UseClipboardProps) => {
const initialContext: clipboard.Context = {
id: useId(),
getRootNode: useEnvironmentContext(),
...props,
}

const context: clipboard.Context = {
...initialContext,
}

const [state, send] = useMachine(clipboard.machine(initialContext), { context })

return clipboard.connect(state, send, normalizeProps)
}
1 change: 1 addition & 0 deletions packages/frameworks/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './accordion'
export * from './avatar'
export * from './carousel'
export * from './checkbox'
export * from './clipboard'
export * from './color-picker'
export * from './combobox'
export * from './date-picker'
Expand Down
8 changes: 8 additions & 0 deletions packages/frameworks/react/src/setup-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ window.Element.prototype.scrollIntoView = () => {}
window.requestAnimationFrame = (cb) => setTimeout(cb, 1000 / 60)
window.URL.createObjectURL = () => 'https://i.pravatar.cc/300'

Object.defineProperty(window, 'navigator', {
value: {
clipboard: {
writeText: vi.fn(),
},
},
})

Object.assign(global, { window, document: window.document })

export const getParts = (anatomy: AnatomyInstance<string>) => {
Expand Down
9 changes: 9 additions & 0 deletions packages/frameworks/solid/src/clipboard/clipboard-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createContext } from '../create-context'
import { type UseClipboardReturn } from './use-clipboard'

export interface ClipboardContext extends UseClipboardReturn {}

export const [ClipboardProvider, useClipboardContext] = createContext<ClipboardContext>({
hookName: 'useClipboardContext',
providerName: '<ClipboardProvider />',
})
12 changes: 12 additions & 0 deletions packages/frameworks/solid/src/clipboard/clipboard-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { mergeProps } from '@zag-js/solid'
import { ark, type ArkComponent, type HTMLArkProps } from '../factory'
import { useClipboardContext } from './clipboard-context'

export interface ClipboardControlProps extends HTMLArkProps<'div'> {}

export const ClipboardControl: ArkComponent<'div'> = (props: ClipboardControlProps) => {
const api = useClipboardContext()
const mergedProps = mergeProps(() => api().controlProps, props)

return <ark.div {...mergedProps} />
}
Loading

0 comments on commit 6b47df9

Please sign in to comment.