{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-field",
  "title": "Copy field",
  "description": "Copyable inline chips, fields and scrollable read-outs with masked values and clipboard feedback.",
  "dependencies": [
    "class-variance-authority@^0.7"
  ],
  "registryDependencies": [
    "@dwellir/dwellir-theme",
    "@dwellir/utils",
    "@dwellir/button",
    "@dwellir/rune-icons"
  ],
  "files": [
    {
      "path": "components/ui/copy-field.tsx",
      "content": "'use client';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { type ComponentProps, type MouseEvent, useId, useState } from 'react';\nimport { RuneCheck, RuneCopy, RuneEye, RuneEyeOff } from '@/components/brand/rune-icons';\nimport { Button } from '@/components/ui/button';\nimport { useCopyFeedback } from '@/hooks/use-copy-feedback';\nimport { cn } from '@/lib/utils';\n\nconst copyFieldVariants = cva('group/copy-field min-w-0 max-w-full gap-1.5 align-middle', {\n  variants: {\n    variant: {\n      inline: 'inline-flex flex-col',\n      field: 'flex w-full flex-col',\n      multiline: 'flex w-full flex-col',\n    },\n  },\n  defaultVariants: { variant: 'field' },\n});\n\nconst readoutVariants = cva('min-w-0 flex-1 font-mono text-sm', {\n  variants: {\n    variant: {\n      inline: 'truncate',\n      field:\n        'h-11 w-full cursor-pointer truncate border-0 bg-transparent py-3 pr-12 pl-3 text-lg outline-none group-data-[disabled=true]/copy-field:cursor-not-allowed md:pointer-fine:text-sm',\n      multiline:\n        'h-48 overflow-y-auto py-3 whitespace-pre-wrap wrap-anywhere outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',\n    },\n    masked: { true: '', false: '' },\n  },\n  compoundVariants: [{ variant: 'field', masked: true, className: 'pr-24' }],\n  defaultVariants: { variant: 'field' },\n});\n\n/*\n * The whole control is the copy target. Its hover and press fills and the focus ring on the\n * copy button come from `ui-copy-field-control`; the variants set the edge and corners.\n * The inline chip is 32px, so it sits in a line of text; a multi-line read-out keeps its\n * buttons at its top. The chip's corner is its buttons' corner plus their 4px inset.\n */\nconst controlVariants = cva(\n  'ui-copy-field-control relative flex min-w-0 cursor-pointer touch-manipulation items-start gap-1 border text-foreground transition-colors motion-reduce:transition-none group-data-[disabled=true]/copy-field:cursor-not-allowed group-data-[disabled=true]/copy-field:text-muted-foreground',\n  {\n    variants: {\n      variant: {\n        inline:\n          'h-8 items-center rounded-lg border-border bg-muted pr-0.75 pl-2.5 hover:border-accent-ink-40',\n        field: 'rounded-control border-input bg-field hover:border-input-hover',\n        multiline: 'rounded-control-max-2xl border-input bg-field px-3 hover:border-input-hover',\n      },\n    },\n    defaultVariants: { variant: 'field' },\n  },\n);\n\nconst overlayButtonVariants = cva(\n  'absolute top-1/2 -translate-y-1/2 before:absolute before:-inset-1.5 aria-disabled:aria-busy:pointer-events-auto',\n  {\n    variants: { action: { copy: 'right-1 focus-visible:outline-none', reveal: 'right-12' } },\n    defaultVariants: { action: 'copy' },\n  },\n);\n\n/*\n * An inline chip's buttons draw at 24px and reach 44px tall through ::before. The\n * reveal button's reach covers the end of the masked dots, not the copy button; the\n * copy button needs no more width, as the whole chip copies.\n */\nconst chipButtonVariants = cva('ui-copy-field-button', {\n  variants: {\n    variant: {\n      inline:\n        'relative size-6 rounded-sm before:absolute before:-inset-y-2.5 data-[slot=copy-field-copy]:before:-right-0.75 data-[slot=copy-field-copy]:before:-left-0.5 data-[slot=copy-field-reveal]:before:-right-0.5 data-[slot=copy-field-reveal]:before:-left-3.5',\n      multiline: 'size-11 rounded-md',\n    },\n  },\n  defaultVariants: { variant: 'inline' },\n});\n\nfunction CopyFieldButton({\n  variant,\n  action,\n  ...props\n}: ComponentProps<'button'> & {\n  variant: CopyFieldProps['variant'];\n  action?: 'copy' | 'reveal';\n}) {\n  if (variant === 'field') {\n    return (\n      <Button\n        type=\"button\"\n        variant=\"secondary\"\n        size=\"icon-sm\"\n        className={cn(overlayButtonVariants({ action }))}\n        {...props}\n      />\n    );\n  }\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        chipButtonVariants({ variant: variant === 'multiline' ? 'multiline' : 'inline' }),\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CopyReadout({\n  id,\n  variant,\n  name,\n  disabled,\n  masked,\n  children,\n}: {\n  id: string;\n  variant: CopyFieldProps['variant'];\n  name: string;\n  disabled?: boolean;\n  masked?: boolean;\n  children: string;\n}) {\n  if (variant === 'field') {\n    return (\n      <input\n        id={id}\n        data-slot=\"copy-field-value\"\n        type=\"text\"\n        aria-label={name}\n        readOnly\n        disabled={disabled}\n        value={children}\n        spellCheck={false}\n        autoComplete=\"off\"\n        // The copy button is the one tab stop; a pointer can still focus the text to select it.\n        tabIndex={-1}\n        className={cn(readoutVariants({ variant, masked }))}\n      />\n    );\n  }\n  if (variant === 'multiline') {\n    return (\n      // biome-ignore lint/a11y/useSemanticElements: a phrasing container keeps the field valid inside running text.\n      <span\n        id={id}\n        data-slot=\"copy-field-readout\"\n        role=\"region\"\n        aria-label={name}\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: this read-out scrolls with the keyboard.\n        tabIndex={0}\n        className={cn(readoutVariants({ variant }))}\n      >\n        <code data-slot=\"copy-field-value\">{children}</code>\n      </span>\n    );\n  }\n  return (\n    <code id={id} data-slot=\"copy-field-value\" className={cn(readoutVariants({ variant }))}>\n      {children}\n    </code>\n  );\n}\n\n/* A drag that selected text is not a copy click: the text stays selectable. */\nfunction hasSelection(control: HTMLElement) {\n  const input = control.querySelector('input');\n  if (input && input.selectionStart !== input.selectionEnd) return true;\n  return Boolean(window.getSelection()?.toString());\n}\n\nfunction CopyStatus({ status }: { status: string }) {\n  return (\n    <span\n      data-slot=\"copy-field-status\"\n      role=\"status\"\n      aria-live=\"polite\"\n      aria-atomic=\"true\"\n      className={status === 'error' ? 'text-sm text-destructive' : 'sr-only'}\n    >\n      {status === 'copied' ? 'Copied' : null}\n      {status === 'error' ? 'Could not copy. Try again.' : null}\n    </span>\n  );\n}\n\nfunction CopyFieldLabel({\n  label,\n  variant,\n  htmlFor,\n}: {\n  label?: string;\n  variant: CopyFieldProps['variant'];\n  htmlFor: string;\n}) {\n  if (!label) return null;\n  const Label = variant === 'field' ? 'label' : 'span';\n  return (\n    <Label\n      data-slot=\"copy-field-label\"\n      htmlFor={variant === 'field' ? htmlFor : undefined}\n      className=\"text-sm font-normal text-foreground\"\n    >\n      {label}\n    </Label>\n  );\n}\n\ntype CopyFieldProps = Omit<ComponentProps<'span'>, 'children' | 'onCopy'> &\n  VariantProps<typeof copyFieldVariants> & {\n    value: string;\n    /** The visible label also names the copy button. */\n    label?: string;\n    /** Names an unlabelled field without exposing its value to assistive technology. */\n    name?: string;\n    masked?: boolean;\n    disabled?: boolean;\n    onCopy?: () => void;\n  };\n\nfunction useMaskedValue(value: string, masked: boolean) {\n  const [state, setState] = useState({ value, masked, revealed: false });\n  if (state.value !== value || state.masked !== masked) {\n    setState({ value, masked, revealed: false });\n  }\n  const revealed = state.value === value && state.masked === masked && state.revealed;\n  return {\n    revealed,\n    text: masked && !revealed ? '••••••••••••••••' : value,\n    toggle: () => setState({ value, masked, revealed: !revealed }),\n  };\n}\n\nfunction CopyField({\n  id,\n  value,\n  label,\n  name = label ?? 'value',\n  variant = 'field',\n  masked = false,\n  disabled = false,\n  onCopy,\n  className,\n  ...props\n}: CopyFieldProps) {\n  const { status, copy } = useCopyFeedback(value, onCopy);\n  const { revealed, text, toggle } = useMaskedValue(value, masked);\n  const generatedId = useId();\n  const contentId = id ?? generatedId;\n  const copyValue = () => {\n    if (!disabled && status !== 'pending') void copy();\n  };\n  const RevealIcon = revealed ? RuneEyeOff : RuneEye;\n  const CopyIconState = status === 'copied' ? RuneCheck : RuneCopy;\n\n  return (\n    <span\n      data-slot=\"copy-field\"\n      data-variant={variant}\n      data-disabled={disabled || undefined}\n      // oxlint-disable-next-line shadcn/no-unknown-classes -- PostHog privacy markers have no CSS declarations.\n      className={cn(copyFieldVariants({ variant }), masked && 'ph-no-capture ph-mask', className)}\n      {...props}\n    >\n      <CopyFieldLabel label={label} variant={variant} htmlFor={contentId} />\n      {/* The copy button supplies keyboard activation; this span extends its pointer target. */}\n      <span\n        data-slot=\"copy-field-control\"\n        data-variant={variant}\n        onClick={(event: MouseEvent<HTMLSpanElement>) => {\n          if (!hasSelection(event.currentTarget)) copyValue();\n        }}\n        className={cn(controlVariants({ variant }))}\n      >\n        <CopyReadout\n          id={contentId}\n          variant={variant}\n          name={name}\n          disabled={disabled}\n          masked={masked}\n        >\n          {text}\n        </CopyReadout>\n        {masked ? (\n          <CopyFieldButton\n            variant={variant}\n            action=\"reveal\"\n            data-slot=\"copy-field-reveal\"\n            aria-label={`${revealed ? 'Hide' : 'Reveal'} ${name}`}\n            aria-pressed={revealed}\n            aria-controls={contentId}\n            disabled={disabled}\n            onClick={(event) => {\n              event.stopPropagation();\n              toggle();\n            }}\n          >\n            <RevealIcon data-slot=\"copy-field-reveal-icon\" aria-hidden=\"true\" className=\"size-4\" />\n          </CopyFieldButton>\n        ) : null}\n        <CopyFieldButton\n          variant={variant}\n          data-slot=\"copy-field-copy\"\n          aria-label={`Copy ${name}`}\n          aria-busy={status === 'pending' || undefined}\n          aria-disabled={status === 'pending' || undefined}\n          disabled={disabled}\n          onClick={(event) => {\n            event.stopPropagation();\n            copyValue();\n          }}\n        >\n          <CopyIconState\n            data-slot=\"copy-field-copy-icon\"\n            aria-hidden=\"true\"\n            className={cn('size-4', status === 'copied' && 'text-accent-ink')}\n          />\n        </CopyFieldButton>\n      </span>\n      <CopyStatus status={status} />\n    </span>\n  );\n}\n\nexport { CopyField, type CopyFieldProps, copyFieldVariants };\n",
      "type": "registry:ui"
    },
    {
      "path": "hooks/use-copy-feedback.ts",
      "content": "'use client';\n\nimport { useLayoutEffect, useRef, useState } from 'react';\n\ntype CopyStatus = 'idle' | 'pending' | 'copied' | 'error';\n\n/** Copy feedback belongs to the current value, even when a clipboard write resolves late. */\nexport function useCopyFeedback(value: string, onCopy?: () => void) {\n  const [result, setResult] = useState<{ value: string; status: CopyStatus }>({\n    value,\n    status: 'idle',\n  });\n  const request = useRef(0);\n  const busy = useRef(false);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useLayoutEffect(() => {\n    busy.current = false;\n    setResult({ value, status: 'idle' });\n    return () => {\n      request.current += 1;\n      clearTimeout(timer.current);\n    };\n  }, [value]);\n\n  async function copy() {\n    if (busy.current) return;\n    busy.current = true;\n    const current = ++request.current;\n    clearTimeout(timer.current);\n    setResult({ value, status: 'pending' });\n    try {\n      await navigator.clipboard.writeText(value);\n    } catch {\n      if (current !== request.current) return;\n      busy.current = false;\n      setResult({ value, status: 'error' });\n      return;\n    }\n    if (current !== request.current) return;\n    busy.current = false;\n    setResult({ value, status: 'copied' });\n    timer.current = setTimeout(() => setResult({ value, status: 'idle' }), 2000);\n    onCopy?.();\n  }\n\n  return { status: result.value === value ? result.status : 'idle', copy };\n}\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:ui"
}