{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "circular-progress",
  "title": "Circular Progress",
  "type": "registry:ui",
  "description": "Radial/circular progress indicator. Supports value (0-100), size presets or custom pixel size, stroke thickness, custom arc and track colors, indeterminate spinning mode, a label slot for center content, and a show-value prop that renders the percentage in the center.",
  "categories": [
    "feedback",
    "display"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/circular-progress/circular-progress.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { circularProgressVariants } from './circular-progress.variants'\n\nexport type CircularProgressSize = 'sm' | 'default' | 'lg' | number\n\nconst sizePxMap = {\n  sm: 40,\n  default: 56,\n  lg: 80,\n} as const\n\n/**\n * <uip-circular-progress> — the registry CircularProgress as a web component.\n *\n * Class strings are React's verbatim, on the same elements (root div,\n * track/arc circles, center overlay). React's injected keyframes\n * (spin-circular, circular-progress-complete) are copied verbatim into the\n * static styles — the one allowed CSS exception for React-injected keyframes.\n *\n * `size` is sm | default | lg or a pixel number (`size=\"120\"`). `value` is\n * clamped to 0–100; reaching 100 fires the same one-shot completion pulse as\n * React (only when the value crosses into complete, not on static mounts).\n * The default slot replaces the center label (React's `children`); `suffix`\n * defaults to `%`.\n *\n * Parts: `base` (the progressbar root), `track`, `arc`, `label`.\n */\nexport class UipCircularProgress extends LitElement {\n  static styles = [\n    tailwind,\n    css`:host { display: inline-flex; }`,\n    // React's injected <style>, verbatim (shadow-scoped: the classes match the\n    // inner elements directly).\n    css`\n      @media (prefers-reduced-motion: no-preference) {\n        .animate-spin-circular {\n          animation: spin-circular 1.4s linear infinite;\n        }\n\n        /* Soft acknowledge when the arc lands on 100 — one shot per enter. */\n        .animate-circular-complete {\n          animation: circular-progress-complete 0.55s ease-out 1;\n        }\n      }\n\n      @keyframes spin-circular {\n        from {\n          transform: rotate(0deg);\n        }\n        to {\n          transform: rotate(360deg);\n        }\n      }\n\n      @keyframes circular-progress-complete {\n        0%,\n        100% {\n          opacity: 1;\n        }\n        45% {\n          opacity: 0.72;\n        }\n      }\n    `,\n  ]\n\n  static properties = {\n    value: { type: Number },\n    size: {\n      converter: {\n        fromAttribute: (v: string | null) => {\n          if (v === null || v === '') return 'default'\n          const n = Number(v)\n          return Number.isNaN(n) ? v : n\n        },\n        toAttribute: (v: unknown) => String(v),\n      },\n    },\n    thickness: { type: Number },\n    color: {},\n    trackColor: { attribute: 'track-color' },\n    indeterminate: { type: Boolean, reflect: true },\n    showValue: { type: Boolean, attribute: 'show-value' },\n    suffix: {},\n    accessibleLabel: { attribute: 'aria-label' },\n    pulseComplete: { state: true },\n    hasSlotted: { state: true },\n  }\n\n  value = 0\n  size: CircularProgressSize = 'default'\n  thickness = 8\n  color?: string\n  trackColor?: string\n  indeterminate = false\n  showValue = false\n  suffix = '%'\n  accessibleLabel = 'Progress'\n  private pulseComplete = false\n  private hasSlotted = false\n  private prevValue: number | undefined = undefined\n  private raf = 0\n  private timer?: ReturnType<typeof setTimeout>\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'circular-progress')\n    // The slot only renders when there is content to show, so detect light-DOM\n    // children here too (frameworks may add them after connect).\n    this.syncSlotted()\n    this.childObserver = new MutationObserver(() => this.syncSlotted())\n    this.childObserver.observe(this, { childList: true })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    cancelAnimationFrame(this.raf)\n    clearTimeout(this.timer)\n    this.childObserver?.disconnect()\n  }\n\n  private childObserver?: MutationObserver\n\n  private syncSlotted() {\n    this.hasSlotted = [...this.childNodes].some((n) => n.nodeType === 1 || (n.textContent?.trim() ?? '') !== '')\n  }\n\n  private get sizePx() {\n    if (typeof this.size === 'number') return this.size\n    return sizePxMap[this.size] ?? 56\n  }\n\n  private get normalizedValue() {\n    return Math.min(100, Math.max(0, this.value))\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    // One-shot pulse only when value crosses into complete — not on static\n    // 100 mounts (mirrors React's prevValueRef effect).\n    if (!changed.has('value') && !changed.has('indeterminate')) return\n    const v = this.normalizedValue\n    if (this.indeterminate || v < 100) {\n      this.pulseComplete = false\n      this.prevValue = v\n      return\n    }\n    const prev = this.prevValue\n    this.prevValue = v\n    if (prev === undefined || prev >= 100) return\n    this.pulseComplete = false\n    cancelAnimationFrame(this.raf)\n    clearTimeout(this.timer)\n    this.raf = requestAnimationFrame(() => (this.pulseComplete = true))\n    this.timer = setTimeout(() => (this.pulseComplete = false), 600)\n  }\n\n  render() {\n    const sizePx = this.sizePx\n    const thickness = this.thickness\n    const normalizedValue = this.normalizedValue\n    const radius = (sizePx - thickness) / 2\n    const circumference = 2 * Math.PI * radius\n    const strokeDashoffset = this.indeterminate ? circumference * 0.25 : circumference * (1 - normalizedValue / 100)\n    const resolvedColor = this.color || 'var(--primary)'\n    const resolvedTrackColor = this.trackColor || 'var(--muted)'\n    const center = sizePx / 2\n    const isComplete = !this.indeterminate && normalizedValue >= 100\n    const fontSize = sizePx <= 40 ? 'text-xs' : sizePx <= 56 ? 'text-sm' : 'text-base'\n\n    return html`<div\n      part=\"base\"\n      data-uipkge=\"\"\n      data-slot=\"circular-progress\"\n      data-size=${typeof this.size === 'string' ? this.size : 'custom'}\n      data-indeterminate=${this.indeterminate ? 'true' : 'false'}\n      data-complete=${isComplete ? 'true' : 'false'}\n      class=${circularProgressVariants()}\n      style=${styleMap({ width: `${sizePx}px`, height: `${sizePx}px` })}\n      role=\"progressbar\"\n      aria-valuemin=\"0\"\n      aria-valuemax=\"100\"\n      aria-valuenow=${this.indeterminate ? nothing : normalizedValue}\n      aria-busy=${this.indeterminate ? 'true' : nothing}\n      aria-label=${this.accessibleLabel}\n    >\n      <svg width=${sizePx} height=${sizePx} viewBox=${`0 0 ${sizePx} ${sizePx}`} class=\"block\">\n        <circle\n          part=\"track\"\n          cx=${center}\n          cy=${center}\n          r=${radius}\n          fill=\"none\"\n          stroke=${resolvedTrackColor}\n          stroke-width=${thickness}\n        ></circle>\n        <g\n          transform=${this.indeterminate ? nothing : `rotate(-90 ${center} ${center})`}\n          class=${this.indeterminate ? 'animate-spin-circular' : ''}\n          style=${this.indeterminate ? styleMap({ transformBox: 'fill-box', transformOrigin: 'center' }) : nothing}\n        >\n          <circle\n            part=\"arc\"\n            cx=${center}\n            cy=${center}\n            r=${radius}\n            fill=\"none\"\n            stroke=${resolvedColor}\n            stroke-width=${thickness}\n            stroke-linecap=\"round\"\n            stroke-dasharray=${circumference}\n            stroke-dashoffset=${strokeDashoffset}\n            class=${cn(\n              !this.indeterminate && 'transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none',\n              this.pulseComplete && 'animate-circular-complete',\n            )}\n          ></circle>\n        </g>\n      </svg>\n      ${this.showValue || this.hasSlotted\n        ? html`<div class=\"absolute inset-0 flex items-center justify-center\">\n            <slot>\n              <span part=\"label\" class=${cn('text-foreground font-medium tabular-nums', fontSize)}>\n                ${Math.round(normalizedValue)}${this.suffix}\n              </span>\n            </slot>\n          </div>`\n        : nothing}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-circular-progress') || customElements.define('uip-circular-progress', UipCircularProgress)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-circular-progress': UipCircularProgress\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/circular-progress/circular-progress.ts"
    },
    {
      "path": "packages/elements-lit/src/components/circular-progress/circular-progress.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\n/**\n * Variant definitions live in their own file (rather than the package\n * `index.ts`) so components can import `circularProgressVariants` from './circular-progress.variants' without creating a circular dependency through the index.\n */\nexport const circularProgressVariants = cva('relative inline-flex items-center justify-center')\n\nexport type CircularProgressVariants = VariantProps<typeof circularProgressVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/circular-progress/circular-progress.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
