{"name":"checkbox","type":"registry:component","title":"Checkbox","description":"A control that allows the user to toggle between checked and not checked. Built with Lit and styled using Tailwind CSS. Supports form integration and indeterminate state.","categories":["ui","form","checkbox","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","dependencies":["lucide-static"],"files":[{"path":"registry/ui/checkbox/checkbox.ts","type":"registry:ui","content":"import { cva } from \"class-variance-authority\";\nimport { html, LitElement, nothing, type PropertyValues } from \"lit\";\nimport { customElement, property, state } from \"lit/decorators.js\";\nimport { unsafeSVG } from \"lit/directives/unsafe-svg.js\";\nimport { Check, Minus } from \"lucide-static\";\nimport { TW } from \"@/registry/lib/tailwindMixin\";\n\nexport const checkboxVariants = cva(\n  \"peer size-4 shrink-0 rounded-sm border border-primary shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground\",\n);\n\nexport interface CheckboxChangeEvent extends CustomEvent {\n  detail: { checked: boolean; indeterminate: boolean };\n}\n\nexport interface CheckboxProperties {\n  checked?: boolean;\n  defaultChecked?: boolean;\n  indeterminate?: boolean;\n  disabled?: boolean;\n  required?: boolean;\n  name?: string;\n  value?: string;\n  ariaLabel?: string | null;\n  ariaLabelledby?: string | null;\n  ariaDescribedby?: string | null;\n}\n\n@customElement(\"ui-checkbox\")\nexport class Checkbox extends TW(LitElement) implements CheckboxProperties {\n  static formAssociated = true;\n  private internals: ElementInternals;\n\n  @property({ type: Boolean }) checked: boolean | undefined = undefined;\n  @property({ type: Boolean, attribute: \"default-checked\" })\n  defaultChecked = false;\n  @property({ type: Boolean }) indeterminate = false;\n  @property({ type: Boolean }) disabled = false;\n  @property({ type: Boolean }) required = false;\n  @property({ type: String }) name = \"\";\n  @property({ type: String }) value = \"on\";\n\n  @property({ type: String, attribute: \"aria-label\" }) accessor ariaLabel:\n    | string\n    | null = null;\n  @property({ type: String, attribute: \"aria-labelledby\" })\n  accessor ariaLabelledby: string | null = null;\n  @property({ type: String, attribute: \"aria-describedby\" })\n  accessor ariaDescribedby: string | null = null;\n\n  @state() private _checked = false;\n\n  private _labelClickHandler = (e: Event) => {\n    const label = e.currentTarget as HTMLLabelElement;\n    if (label.htmlFor === this.id && !this.disabled) {\n      e.preventDefault();\n      this._handleClick();\n    }\n  };\n\n  constructor() {\n    super();\n    this.internals = this.attachInternals();\n  }\n\n  override connectedCallback() {\n    super.connectedCallback();\n    if (this.checked === undefined) {\n      this._checked = this.defaultChecked;\n    }\n    if (this.shadowRoot) {\n      const sheet = new CSSStyleSheet();\n      sheet.replaceSync(\n        `:host { display: inline-flex; vertical-align: middle; line-height: 1; }`,\n      );\n      this.shadowRoot.adoptedStyleSheets = [\n        ...this.shadowRoot.adoptedStyleSheets,\n        sheet,\n      ];\n    }\n    this._setupLabelDelegation();\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this._cleanupLabelDelegation();\n  }\n\n  private _setupLabelDelegation() {\n    if (!this.id) return;\n    const root = this.getRootNode() as Document | ShadowRoot;\n    const labels = root.querySelectorAll(\n      `label[for=\"${this.id}\"]`,\n    ) as NodeListOf<HTMLLabelElement>;\n    labels.forEach((label) => {\n      label.addEventListener(\"click\", this._labelClickHandler);\n    });\n  }\n\n  private _cleanupLabelDelegation() {\n    if (!this.id) return;\n    const root = this.getRootNode() as Document | ShadowRoot;\n    const labels = root.querySelectorAll(\n      `label[for=\"${this.id}\"]`,\n    ) as NodeListOf<HTMLLabelElement>;\n    labels.forEach((label) => {\n      label.removeEventListener(\"click\", this._labelClickHandler);\n    });\n  }\n\n  private get isChecked(): boolean {\n    return this.checked !== undefined ? this.checked : this._checked;\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (\n      changedProperties.has(\"checked\") ||\n      changedProperties.has(\"_checked\") ||\n      changedProperties.has(\"indeterminate\") ||\n      changedProperties.has(\"disabled\")\n    ) {\n      const state = this.indeterminate\n        ? \"indeterminate\"\n        : this.isChecked\n          ? \"checked\"\n          : \"unchecked\";\n      this.setAttribute(\"data-state\", state);\n\n      const ariaChecked = this.indeterminate ? \"mixed\" : String(this.isChecked);\n      this.setAttribute(\"aria-checked\", ariaChecked);\n\n      if (this.disabled) {\n        this.setAttribute(\"data-disabled\", \"\");\n      } else {\n        this.removeAttribute(\"data-disabled\");\n      }\n\n      this.internals.setFormValue(this.isChecked ? this.value : null);\n    }\n  }\n\n  override attributeChangedCallback(\n    name: string,\n    _old: string | null,\n    value: string | null,\n  ) {\n    super.attributeChangedCallback(name, _old, value);\n    if (name === \"id\" && _old !== value) {\n      this._cleanupLabelDelegation();\n      this._setupLabelDelegation();\n    }\n  }\n\n  private _handleClick() {\n    if (this.disabled) return;\n\n    if (this.checked === undefined) {\n      this._checked = !this._checked;\n    }\n\n    this.dispatchEvent(\n      new CustomEvent(\"change\", {\n        detail: {\n          checked: this.checked !== undefined ? !this.checked : this._checked,\n          indeterminate: false,\n        },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  }\n\n  private _handleKeyDown(e: KeyboardEvent) {\n    if (this.disabled) return;\n    if (e.key === \" \" || e.key === \"Enter\") {\n      e.preventDefault();\n      this._handleClick();\n    }\n  }\n\n  override render() {\n    return html`\n      <button\n        type=\"button\"\n        role=\"checkbox\"\n        class=${checkboxVariants()}\n        ?disabled=${this.disabled}\n        ?required=${this.required}\n        aria-checked=${\n          this.indeterminate ? \"mixed\" : (String(this.isChecked) as \"true\")\n        }\n        aria-label=${this.ariaLabel || nothing}\n        aria-labelledby=${this.ariaLabelledby || nothing}\n        aria-describedby=${this.ariaDescribedby || nothing}\n        data-state=${\n          this.indeterminate\n            ? \"indeterminate\"\n            : this.isChecked\n              ? \"checked\"\n              : \"unchecked\"\n        }\n        @click=${this._handleClick}\n        @keydown=${this._handleKeyDown}\n      >\n        <span\n          class=\"flex [&>svg]:size-3 items-center justify-center text-current\"\n        >\n          ${\n            this.indeterminate\n              ? unsafeSVG(Minus)\n              : this.isChecked\n                ? unsafeSVG(Check)\n                : nothing\n          }\n        </span>\n      </button>\n    `;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-checkbox\": Checkbox;\n  }\n}\n"},{"path":"registry/ui/checkbox/checkbox.stories.ts","type":"registry:ui","content":"import \"./checkbox\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport type { CheckboxProperties } from \"./checkbox\";\n\ntype CheckboxArgs = CheckboxProperties;\n\nconst meta: Meta<CheckboxArgs> = {\n  title: \"ui/Checkbox\",\n  component: \"ui-checkbox\",\n  tags: [\"autodocs\"],\n  argTypes: {\n    checked: {\n      control: \"boolean\",\n      description: \"Controlled checked state\",\n    },\n    defaultChecked: {\n      control: \"boolean\",\n      description: \"Initial checked state for uncontrolled mode\",\n    },\n    indeterminate: {\n      control: \"boolean\",\n      description: \"Indeterminate state (partial selection)\",\n    },\n    disabled: {\n      control: \"boolean\",\n      description: \"Whether checkbox is disabled\",\n    },\n    required: {\n      control: \"boolean\",\n      description: \"Whether checkbox is required\",\n    },\n  },\n  args: {\n    disabled: false,\n    required: false,\n    indeterminate: false,\n  },\n  parameters: {\n    layout: \"centered\",\n  },\n  render: (args) => html`\n    <ui-checkbox\n      ?checked=${args.checked}\n      ?default-checked=${args.defaultChecked}\n      ?indeterminate=${args.indeterminate}\n      ?disabled=${args.disabled}\n      ?required=${args.required}\n    ></ui-checkbox>\n  `,\n};\n\nexport default meta;\ntype Story = StoryObj<CheckboxArgs>;\n\nexport const Default: Story = {};\n\nexport const Checked: Story = {\n  args: {\n    defaultChecked: true,\n  },\n};\n\nexport const Disabled: Story = {\n  args: {\n    disabled: true,\n  },\n};\n\nexport const DisabledChecked: Story = {\n  args: {\n    disabled: true,\n    defaultChecked: true,\n  },\n};\n\nexport const Indeterminate: Story = {\n  args: {\n    indeterminate: true,\n  },\n};\n\nexport const WithLabel: Story = {\n  render: (args) => html`\n    <div class=\"flex items-center gap-3\">\n      <ui-checkbox\n        id=\"terms\"\n        ?disabled=${args.disabled}\n        aria-label=\"Accept terms and conditions\"\n      ></ui-checkbox>\n      <label class=\"ui-label\" for=\"terms\">Accept terms and conditions</label>\n    </div>\n  `,\n};\n\nexport const WithLabelAndDescription: Story = {\n  render: (args) => html`\n    <div class=\"flex items-start gap-3\">\n      <ui-checkbox\n        id=\"marketing\"\n        ?disabled=${args.disabled}\n        aria-label=\"Marketing emails\"\n      ></ui-checkbox>\n      <div class=\"grid gap-1.5 leading-none\">\n        <label class=\"ui-label font-medium\" for=\"marketing\"\n          >Marketing emails</label\n        >\n        <p class=\"text-sm text-muted-foreground\">\n          Receive emails about new products, features, and more.\n        </p>\n      </div>\n    </div>\n  `,\n};\n\nexport const MultipleCheckboxes: Story = {\n  render: () => html`\n    <div class=\"grid gap-4\">\n      <div class=\"flex items-center gap-3\">\n        <ui-checkbox\n          id=\"item-1\"\n          aria-label=\"Item 1\"\n          default-checked\n        ></ui-checkbox>\n        <label class=\"ui-label\" for=\"item-1\">Item 1</label>\n      </div>\n      <div class=\"flex items-center gap-3\">\n        <ui-checkbox id=\"item-2\" aria-label=\"Item 2\"></ui-checkbox>\n        <label class=\"ui-label\" for=\"item-2\">Item 2</label>\n      </div>\n      <div class=\"flex items-center gap-3\">\n        <ui-checkbox id=\"item-3\" aria-label=\"Item 3\"></ui-checkbox>\n        <label class=\"ui-label\" for=\"item-3\">Item 3</label>\n      </div>\n    </div>\n  `,\n};\n\nexport const InForm: Story = {\n  render: () => html`\n    <form\n      @submit=${(e: Event) => {\n        e.preventDefault();\n        const formData = new FormData(e.target as HTMLFormElement);\n        console.log(\"Form submitted:\", Object.fromEntries(formData));\n      }}\n      class=\"grid gap-4\"\n    >\n      <div class=\"flex items-center gap-3\">\n        <ui-checkbox\n          name=\"subscribe\"\n          value=\"yes\"\n          aria-label=\"Subscribe to newsletter\"\n        ></ui-checkbox>\n        <label class=\"ui-label\" for=\"subscribe\">Subscribe to newsletter</label>\n      </div>\n      <button\n        type=\"submit\"\n        class=\"rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground\"\n      >\n        Submit\n      </button>\n    </form>\n  `,\n};\n"}]}