Skip to contentAsheeUIAsheeUI

Clipboard

Copy a value to the clipboard, and say so, twice, because a label alone is not announced.

Copying is one action with a state that lasts two seconds, and the state is the whole of the component: a value is written, the control says it worked, and then it goes back to being a control. The framework ships it as two halves, CopyButton, a button that already says the right thing, and Clipboard, the headless primitive that owns the writing and the timing and renders whatever a consumer's render function returns.

What separates it from a Button with a clipboard call in its handler is that the result is reported twice. The label changes to Copied and the same wording is written into a live region beside it, because a label that changes under the reader's finger is not announced by a screen reader; the framework's control supplies both so a consumer does not have to remember the second.

Usage

import { CopyButton } from "@asheeui/web";

<CopyButton
  value="INV-1042"
  label="Copy invoice number"
  copiedAnnouncement="Invoice number copied"
/>

value is what is written, and it is the only required prop. label and copiedLabel are the two names of the control, registered as Copy and Copied; copiedAnnouncement is the sentence the live region receives, and without it the announcement is the copied label and the value together, Copied: INV-1042, which is usually too much to hear but always true.

The copied state lasts timeout milliseconds, registered as 2000: long enough to read, short enough that the control is a copy control again by the time a reader wants it. onCopy receives the text that was written, so a screen can record the copy or offer to undo it, and onError receives whatever the browser threw.

copyIcon and copiedIcon replace the drawing before the label. Everything else is a button's: variant, color, size, radius, className, isDisabled and the rest reach the framework's Button unchanged, so a copy control sitting in a table row is a small ghost button and one at the foot of a dialog is whatever the dialog's actions are.

Your own control

Clipboard is the half that does the work. It renders nothing of its own: it takes the value, the timing and a render function, and hands that function the state to draw with, copied, error, copy and reset.

import { Clipboard } from "@asheeui/web";

<Clipboard value={invoice.id}>
  {({ copied, copy }) => (
    <button type="button" onClick={() => void copy()}>
      {copied ? "Copied" : "Copy invoice number"}
    </button>
  )}
</Clipboard>

copy writes the component's value, or a text passed to it, copy(row.email) is how one control copies a different cell of the row it sits in. It resolves to whether the write succeeded, so a consumer can branch without reading the state:

const copied = await copy();
if (!copied) report("The clipboard refused us");

error is the other half of that answer, and it is a state a consumer usually wants to show, because a browser may refuse clipboard access outside a user gesture or without permission. The primitive does not give up silently: when the asynchronous clipboard API is missing it falls back to a temporary selection and document.execCommand("copy"), restoring focus to whatever was focused before, so a page over plain HTTP still copies. reset clears both states without copying, for a control that is dismissed before its two seconds are up.

The announcement is the consumer's. Clipboard renders exactly what the render function returns, so a bare Clipboard that draws a button and no status region copies silently. That is why the preview on this page uses CopyButton: it is the same primitive with the live region already in it. A consumer's own control needs its own role="status" element, as the demo above has.

Props

Clipboard takes the value, the timing, the two callbacks and the render function, and resolves timeout from the instance, then components.clipboard.timeout, then the fallback of 2000.

The registered values for the control are label Copy, copiedLabel Copied, variant bordered, color primary, size sm and timeout 2000, a small bordered button with the product's own accent, because a copy control is a secondary action beside the thing it copies. radius resolves from the instance, then components.clipboard.radius, then the application's defaultRadius, then the fallback sm, the same cascade the Button it renders follows, so a product that rounds its buttons rounds its copy control with them unless it says otherwise.

value, label, copiedLabel, copiedAnnouncement, errorAnnouncement, the two icons and the two callbacks are instance-only. What is being copied, what it is called and what is said about it are statements about one control in one place; the treatment and the timing are statements about the design.

Accessibility

  • The result is reported twice: the label becomes Copied and a sentence is written into a visually hidden role="status" region with aria-live="polite". The region is sr-only, so it stays in the accessibility tree without taking space, and it is the reason a copy is announced at all, a label change alone is not.
  • The control is the same <button type="button"> before and after a copy, so focus never moves: a reader hears the announcement where they are and can copy again immediately. type="button" means it never submits a form it happens to sit in.
  • The announcement names what was copied, by default the copied label and the value, or copiedAnnouncement when a product wants a shorter sentence. Because it is a live region, the sentence replaces the previous one, so a value with a long URL in it is read out in full unless the consumer shortens it.
  • A failure is a state rather than silence, and it is announced as one: the write being refused, or there being nothing to copy, sets error, leaves the label on Copy (a control that could not copy never claims it did) and writes a sentence into the same live region, Copy failed unless errorAnnouncement says otherwise. The consumer still receives onError whether or not it also states its own wording.

Native

@asheeui/native ships both halves with the same props, and copies through the platform's own clipboard module rather than a selection. The package carries no icon set, so the affordances are the characters ⧉ and ✓ unless copyIcon or copiedIcon replaces them, the control's name is its wording rather than the wording plus a glyph, and the status region is a pixel-sized transparent view that stays in the accessibility tree. See React Native.