{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "checkbox",
  "title": "Checkbox",
  "type": "registry:ui",
  "description": "Standalone or in-form binary toggle, built on Radix UI. Supports indeterminate state for tri-state lists, sizes, and proper keyboard / screen-reader behavior. Pair with Label for clickable text.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/checkbox/checkbox.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { Check, LoaderCircle, Minus } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\ntype Size = 'sm' | 'md' | 'lg'\ntype Density = 'compact' | 'default' | 'comfortable'\n\nconst sizeClasses: Record<Size, string> = {\n  sm: 'size-3.5',\n  md: 'size-4',\n  lg: 'size-5',\n}\n\nconst iconSizes: Record<Size, string> = {\n  sm: 'size-2.5',\n  md: 'size-3.5',\n  lg: 'size-4',\n}\n\nconst colorClasses: Record<string, string> = {\n  primary:\n    'data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary',\n  secondary:\n    'data-[state=checked]:bg-secondary data-[state=checked]:border-secondary data-[state=indeterminate]:bg-secondary data-[state=indeterminate]:border-secondary',\n  success:\n    'data-[state=checked]:bg-success data-[state=checked]:border-success data-[state=indeterminate]:bg-success data-[state=indeterminate]:border-success',\n  warning:\n    'data-[state=checked]:bg-warning data-[state=checked]:border-warning data-[state=indeterminate]:bg-warning data-[state=indeterminate]:border-warning',\n  error:\n    'data-[state=checked]:bg-destructive data-[state=checked]:border-destructive data-[state=indeterminate]:bg-destructive data-[state=indeterminate]:border-destructive',\n  info: 'data-[state=checked]:bg-info data-[state=checked]:border-info data-[state=indeterminate]:bg-info data-[state=indeterminate]:border-info',\n}\n\nconst densityClasses: Record<Density, string> = {\n  compact: 'gap-1',\n  default: 'gap-2',\n  comfortable: 'gap-3',\n}\n\n// React's `checkbox-check-in` keyframes (scale .55 → 1.08 → 1) live in an\n// injected <style>; custom keyframes aren't allowed here, so the same entrance\n// uses tw-animate-css utilities (no overshoot). The class name is kept.\nconst indicatorIconClasses =\n  'checkbox-indicator-icon motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-50 motion-safe:duration-200'\n\n/** `error-messages=\"Required\"` or `error-messages='[\"A\",\"B\"]'`. */\nconst messagesConverter = {\n  fromAttribute: (v: string | null) => (v && v.trim().startsWith('[') ? (JSON.parse(v) as string[]) : (v ?? undefined)),\n}\n\nconst messagesList = (m?: string | string[]) => (m == null ? [] : typeof m === 'string' ? (m ? [m] : []) : m)\n\nconst hasErrorMessages = (m?: string | string[]) =>\n  Boolean(m && (typeof m === 'string' ? m : m.length > 0))\n\nlet uid = 0\n\n/**\n * <uip-checkbox> — the registry Checkbox as a web component.\n *\n * Class strings are React's `Checkbox` verbatim. The inner\n * <button role=checkbox> carries `aria-checked` (true | false | mixed) and\n * `data-state` (checked | unchecked | indeterminate) like Radix's\n * Checkbox.Root; Space toggles, Enter doesn't (Radix). Clicking an\n * indeterminate checkbox makes it checked (Radix) and clears `indeterminate`.\n *\n * Props (attributes): `checked` (initial state — the `checked` property is the\n * live one, like <input>), `indeterminate`, `value` (default \"on\"), `size`\n * (sm | md | lg), `color`, `label`, `label-position` (before | after), `hint`,\n * `error`, `error-messages` (string or JSON array), `density`, `hide-icon`,\n * `loading`, `flat`, `inline`, `name`, `disabled`, `required`. Default slot\n * replaces the indicator icon (React's `children`).\n *\n * Form-associated: submits `name=value` when checked, supports `required`,\n * resets to the initial `checked` / `indeterminate` attributes. A light-DOM\n * `<label for>` pointing at the host toggles it and names the inner checkbox.\n *\n * Events: `input`, `change` (read `.checked` / `.indeterminate`), and\n * `checked-change` (React's `onCheckedChange`, `detail: { checked }`).\n */\nexport class UipCheckbox extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    checked: { type: Boolean, attribute: false },\n    defaultChecked: { type: Boolean, attribute: 'checked' },\n    indeterminate: { type: Boolean, reflect: true },\n    value: {},\n    size: { reflect: true },\n    color: {},\n    label: {},\n    labelPosition: { attribute: 'label-position' },\n    hint: {},\n    error: { type: Boolean },\n    errorMessages: { attribute: 'error-messages', converter: messagesConverter },\n    density: {},\n    hideIcon: { type: Boolean, attribute: 'hide-icon' },\n    loading: { type: Boolean },\n    flat: { type: Boolean },\n    inline: { type: Boolean },\n    name: { reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    required: { type: Boolean, reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    labelText: { state: true },\n  }\n\n  /** Current state. The `checked` attribute is the initial (default) state. */\n  checked = false\n  defaultChecked = false\n  indeterminate = false\n  value = 'on'\n  size: Size = 'md'\n  color = 'primary'\n  label?: string\n  labelPosition: 'before' | 'after' = 'after'\n  hint?: string\n  error = false\n  errorMessages?: string | string[]\n  density: Density = 'default'\n  hideIcon = false\n  loading = false\n  flat = false\n  inline = false\n  name?: string\n  disabled = false\n  required = false\n  accessibleLabel?: string\n  private labelText?: string\n  private defaultIndeterminate = false\n  private readonly controlId = `uip-checkbox-${++uid}`\n  private internals = this.attachInternals()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n    // A click on the inner button (or the in-shadow <label>, which forwards a\n    // click to the button) bubbles here; a light-DOM `<label for>` click is\n    // dispatched on the host itself. Other clicks (hint text…) are ignored.\n    this.addEventListener('click', (e) => {\n      const path = e.composedPath()\n      if (path[0] === this || (this.button && path.includes(this.button))) this.toggle()\n    })\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'checkbox')\n    this.defaultIndeterminate = this.hasAttribute('indeterminate')\n  }\n\n  private get state() {\n    return this.indeterminate ? 'indeterminate' : this.checked ? 'checked' : 'unchecked'\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    // Attribute changes drive the state; on first render only a present\n    // attribute applies, so a `.checked = true` set before connect survives.\n    if (changed.has('defaultChecked') && (this.hasUpdated || this.defaultChecked)) this.checked = this.defaultChecked\n    this.setAttribute('data-state', this.state)\n    if (changed.has('checked') || changed.has('required') || changed.has('value')) {\n      this.internals.setFormValue(this.checked ? this.value : null)\n      if (this.required && !this.checked) {\n        this.internals.setValidity({ valueMissing: true }, 'Please check this box.', this.button ?? undefined)\n      } else {\n        this.internals.setValidity({})\n      }\n    }\n    const labels = [...(this.internals.labels ?? [])] as HTMLElement[]\n    this.labelText = labels.map((l) => l.textContent?.trim()).filter(Boolean).join(' ') || undefined\n  }\n\n  formResetCallback() {\n    this.checked = this.defaultChecked\n    this.indeterminate = this.defaultIndeterminate\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get button() {\n    return this.renderRoot?.querySelector<HTMLButtonElement>('[role=checkbox]')\n  }\n\n  private toggle() {\n    if (this.disabled) return\n    this.checked = this.indeterminate ? true : !this.checked\n    this.indeterminate = false\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('checked-change', { detail: { checked: this.checked }, bubbles: true, composed: true }))\n  }\n\n  render() {\n    const size: Size = this.size in sizeClasses ? this.size : 'md'\n    const density: Density = this.density in densityClasses ? this.density : 'default'\n    const hasError = this.error || hasErrorMessages(this.errorMessages)\n    const isIndeterminate = this.indeterminate\n    const state = this.state\n\n    const checkboxClasses = cn(\n      'peer border-input data-[state=checked]:text-primary-foreground data-[state=indeterminate]:text-primary-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shrink-0 rounded-[4px] border shadow-xs transition-colors duration-200 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n      sizeClasses[size],\n      colorClasses[this.color] || colorClasses.primary,\n      hasError &&\n        'border-destructive data-[state=checked]:!bg-destructive data-[state=checked]:!border-destructive data-[state=indeterminate]:!bg-destructive data-[state=indeterminate]:!border-destructive',\n      this.flat && 'shadow-none',\n    )\n\n    const labelClasses = cn(\n      'cursor-pointer text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',\n      hasError ? 'text-destructive' : '',\n      this.disabled && 'cursor-not-allowed opacity-50',\n    )\n\n    const label = (pos: 'before' | 'after') =>\n      this.label && this.labelPosition === pos\n        ? html`<label for=${this.controlId} class=${cn(pos === 'before' ? 'mr-2' : 'ml-2', labelClasses)}\n            >${this.label}</label\n          >`\n        : nothing\n\n    // Radix mounts the indicator only while checked/indeterminate, unless\n    // force-mounted (indeterminate or hideIcon).\n    const showIndicator = state !== 'unchecked' || isIndeterminate || this.hideIcon\n    const iconContent = this.loading\n      ? icon(LoaderCircle, 'loader-circle', cn(iconSizes[size], 'animate-spin'))\n      : isIndeterminate\n        ? icon(Minus, 'minus', cn(iconSizes[size], indicatorIconClasses))\n        : !this.hideIcon\n          ? icon(Check, 'check', cn(iconSizes[size], indicatorIconClasses))\n          : nothing\n\n    const errors = messagesList(this.errorMessages)\n\n    return html`<div class=${cn('flex items-start', densityClasses[density], this.inline ? 'inline-flex' : 'flex-col')}>\n      ${label('before')}\n      <div class=\"flex items-center\">\n        <button\n          part=\"base\"\n          type=\"button\"\n          role=\"checkbox\"\n          id=${this.controlId}\n          data-slot=\"checkbox\"\n          aria-checked=${isIndeterminate ? 'mixed' : this.checked ? 'true' : 'false'}\n          aria-required=${this.required ? 'true' : nothing}\n          aria-invalid=${hasError ? 'true' : nothing}\n          aria-label=${this.accessibleLabel ?? (this.label ? nothing : (this.labelText ?? nothing))}\n          data-state=${state}\n          ?data-disabled=${this.disabled}\n          value=${this.value}\n          ?disabled=${this.disabled}\n          class=${checkboxClasses}\n          @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && e.preventDefault()}\n        >\n          ${showIndicator\n            ? html`<span\n                data-uipkge=\"\"\n                data-slot=\"checkbox-indicator\"\n                data-state=${state}\n                ?data-disabled=${this.disabled}\n                class=\"grid place-content-center text-current transition-none\"\n                ><slot>${iconContent}</slot></span\n              >`\n            : nothing}\n        </button>\n        ${label('after')}\n      </div>\n      ${this.hint && !hasError ? html`<p class=\"text-muted-foreground mt-1 text-xs\">${this.hint}</p>` : nothing}\n      ${hasError\n        ? html`<div class=\"mt-1 flex flex-col gap-0.5\">\n            ${errors.map((msg) => html`<p class=\"text-destructive text-xs\">${msg}</p>`)}\n          </div>`\n        : nothing}\n    </div>`\n  }\n}\n\ninterface CheckboxOption {\n  label: string\n  value: string\n  disabled?: boolean\n}\n\nlet groupUid = 0\n\n/**\n * <uip-checkbox-group> — React's `CheckboxGroup`.\n *\n * Either renders checkboxes from `options` (property, or a JSON attribute:\n * `options='[{\"label\":\"Apple\",\"value\":\"apple\"}]'` / `'[\"A\",\"B\"]'`), or manages\n * slotted <uip-checkbox> children by their `value`. `value` is a string[]\n * (the `value` attribute takes JSON and is the initial/default value).\n *\n * Props: `label`, `hint`, `error`, `error-messages`, `orientation`\n * (vertical | horizontal), `inline`, `bordered`, `density`, `disabled`, `name`.\n *\n * Form-associated: with a `name`, submits one `name=value` entry per checked\n * item (like Radix's hidden inputs) and resets to the initial value.\n *\n * Events: `input`, `change` (read `.value`), and `value-change`\n * (React's `onValueChange`, `detail: { value }` with the new string[]). The individual\n * checkboxes' own events are stopped at the group.\n */\nexport class UipCheckboxGroup extends LitElement {\n  static formAssociated = true\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: { type: Array },\n    options: { type: Array },\n    disabled: { type: Boolean, reflect: true },\n    error: { type: Boolean },\n    errorMessages: { attribute: 'error-messages', converter: messagesConverter },\n    label: {},\n    hint: {},\n    orientation: { reflect: true },\n    bordered: { type: Boolean },\n    density: {},\n    name: { reflect: true },\n    inline: { type: Boolean },\n    accessibleLabel: { attribute: 'aria-label' },\n  }\n\n  value: string[] = []\n  options: (string | CheckboxOption)[] = []\n  disabled = false\n  error = false\n  errorMessages?: string | string[]\n  label?: string\n  hint?: string\n  orientation: 'horizontal' | 'vertical' = 'vertical'\n  bordered = false\n  density: Density = 'default'\n  name?: string\n  inline = false\n  accessibleLabel?: string\n  private defaultValue: string[] = []\n  private readonly labelId = `uip-checkbox-group-${++groupUid}-label`\n  private internals = this.attachInternals()\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', 'checkbox-group')\n    this.defaultValue = [...(this.value ?? [])]\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value') || changed.has('name')) {\n      if (this.name) {\n        const fd = new FormData()\n        for (const v of this.value ?? []) fd.append(this.name, v)\n        this.internals.setFormValue(fd)\n      } else {\n        this.internals.setFormValue(null)\n      }\n    }\n  }\n\n  protected updated() {\n    this.syncChildren()\n  }\n\n  formResetCallback() {\n    this.value = [...this.defaultValue]\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get slottedBoxes() {\n    return [...this.querySelectorAll('uip-checkbox')]\n  }\n\n  // Slotted children follow the group's value / disabled state.\n  private syncChildren() {\n    if (this.options?.length) return\n    for (const box of this.slottedBoxes) {\n      box.checked = (this.value ?? []).includes(box.value)\n      if (this.disabled) box.disabled = true\n    }\n  }\n\n  private onChildChange(e: Event) {\n    const box = e.target as HTMLElement\n    e.stopPropagation()\n    if (e.type !== 'change' || box.localName !== 'uip-checkbox') return\n    const { value, checked } = box as UipCheckbox\n    const selected = this.value ?? []\n    this.value = checked ? [...selected.filter((v) => v !== value), value] : selected.filter((v) => v !== value)\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: this.value }, bubbles: true, composed: true }))\n  }\n\n  private stop(e: Event) {\n    e.stopPropagation()\n  }\n\n  render() {\n    const orientation = this.inline ? 'horizontal' : this.orientation\n    const selected = this.value ?? []\n    const errors = messagesList(this.errorMessages)\n    const options = this.options ?? []\n\n    return html`<div\n      part=\"base\"\n      data-slot=\"checkbox-group\"\n      role=\"group\"\n      data-orientation=${orientation}\n      aria-labelledby=${this.label && !this.accessibleLabel ? this.labelId : nothing}\n      aria-label=${this.accessibleLabel ?? nothing}\n      class=${cn(\n        'flex flex-col gap-2',\n        orientation === 'horizontal' ? 'flex-row items-center' : 'flex-col',\n        this.bordered && 'rounded-lg border p-4',\n      )}\n      @change=${this.onChildChange}\n      @input=${this.stop}\n      @checked-change=${this.stop}\n    >\n      ${this.label ? html`<label id=${this.labelId} class=\"text-sm font-medium\">${this.label}</label>` : nothing}\n      ${this.hint && !this.error ? html`<p class=\"text-muted-foreground text-xs\">${this.hint}</p>` : nothing}\n\n      <div class=${cn('flex gap-4', orientation === 'horizontal' ? 'flex-row flex-wrap items-center' : 'flex-col')}>\n        ${options.length > 0\n          ? options.map((option) => {\n              const optValue = typeof option === 'string' ? option : option.value\n              const optLabel = typeof option === 'string' ? option : option.label\n              const optDisabled = typeof option === 'string' ? false : !!option.disabled\n              return html`<uip-checkbox\n                .value=${optValue}\n                .label=${optLabel}\n                .disabled=${this.disabled || optDisabled}\n                .density=${this.density}\n                .checked=${selected.includes(optValue)}\n              ></uip-checkbox>`\n            })\n          : html`<slot @slotchange=${() => this.syncChildren()}></slot>`}\n      </div>\n\n      ${this.error || this.errorMessages\n        ? html`<div class=\"flex flex-col gap-0.5\">\n            ${errors.map((msg) => html`<p class=\"text-destructive text-xs\">${msg}</p>`)}\n          </div>`\n        : nothing}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-checkbox') || customElements.define('uip-checkbox', UipCheckbox)\ncustomElements.get('uip-checkbox-group') || customElements.define('uip-checkbox-group', UipCheckboxGroup)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-checkbox': UipCheckbox\n    'uip-checkbox-group': UipCheckboxGroup\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/checkbox/checkbox.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
