import { css, html, LitElement } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { BaseElement } from "../../core/base-element.ts";
import { type CellHandle } from "@commonfabric/runtime-client";
import { booleanSchema } from "@commonfabric/runner/schemas";
import { createBooleanCellController } from "../../core/cell-controller.ts";
import { createFormFieldController } from "../../core/form-field-controller.ts";
/**
* CFCheckbox - Binary selection input with support for indeterminate state
*
* @element cf-checkbox
*
* @attr {boolean|CellHandle} checked - Whether the checkbox is checked (supports both plain boolean and CellHandle)
* @attr {boolean} disabled - Whether the checkbox is disabled
* @attr {string} name - Name attribute for form submission
* @attr {string} value - Value attribute for form submission
* @attr {boolean} required - Whether the checkbox is required
* @attr {boolean} indeterminate - Whether the checkbox is in indeterminate state
*
* @slot - Default slot for checkbox label text
*
* @fires cf-change - Fired on change with detail: { checked, indeterminate }
*
* @example
* Accept terms
*
* @example
*
* Enable feature
*/
export class CFCheckbox extends BaseElement {
static override shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
static override styles = css`
:host {
display: inline-flex;
align-items: center;
gap: 0.5rem;
position: relative;
cursor: pointer;
line-height: 1.5;
/* Default color values if not provided */
--background: #ffffff;
--foreground: #0f172a;
--primary: #0f172a;
--primary-foreground: #f8fafc;
--border: #e2e8f0;
--ring: #94a3b8;
}
:host([disabled]) {
cursor: not-allowed;
opacity: 0.5;
}
:host:focus {
outline: none;
}
:host:focus-visible .checkbox {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow:
0 0 0 2px var(--background, #fff),
0 0 0 4px var(--ring, #94a3b8);
}
.checkbox {
position: relative;
width: 1rem; /* size-4 */
height: 1rem; /* size-4 */
border: 1px solid var(--primary, #0f172a);
border-radius: 0.25rem; /* rounded */
background-color: var(--background, #fff);
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
}
.checkbox.checked,
.checkbox.indeterminate {
background-color: var(--primary, #0f172a);
border-color: var(--primary, #0f172a);
}
.checkbox.disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Checkmark using CSS transforms */
.checkmark {
display: none;
width: 10px;
height: 6px;
position: relative;
}
.checkbox.checked .checkmark {
display: block;
}
.checkbox.checked .checkmark::after {
content: "";
position: absolute;
left: 2.5px;
top: -2.5px;
width: 4px;
height: 7px;
border: solid var(--primary-foreground, #f8fafc);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* Indeterminate state - horizontal line */
.checkbox.indeterminate .checkmark {
display: block;
width: 8px;
height: 2px;
background-color: var(--primary-foreground, #f8fafc);
}
.checkbox.indeterminate .checkmark::after {
display: none;
}
/* Hidden native input for form compatibility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Hover state */
:host(:not([disabled]):hover) .checkbox:not(.checked):not(.indeterminate) {
border-color: var(--primary, #0f172a);
}
/* Animation for checkmark */
.checkbox.checked .checkmark::after {
animation: checkmark-animation 200ms ease-out;
}
@keyframes checkmark-animation {
0% {
transform: rotate(45deg) scale(0);
}
100% {
transform: rotate(45deg) scale(1);
}
}
`;
static override properties = {
checked: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
indeterminate: { type: Boolean, reflect: true },
required: { type: Boolean, reflect: true },
name: { type: String },
value: { type: String },
};
declare checked: CellHandle | boolean;
declare disabled: boolean;
declare indeterminate: boolean;
declare required: boolean;
declare name: string;
declare value: string;
private _checkedCellController = createBooleanCellController(this, {
timing: {
strategy: "immediate",
delay: 0,
},
onChange: (newValue: boolean, _oldValue: boolean) => {
this.emit("cf-change", {
checked: newValue,
indeterminate: this.indeterminate,
});
},
});
// Form field controller handles buffering when in cf-form context
private _formField = createFormFieldController(this, {
cellController: this._checkedCellController,
validate: () => {
// Disabled fields should not cause form invalidation
if (this.disabled) {
return { valid: true };
}
// Required checkbox must be checked
if (this.required && !this.getChecked()) {
return { valid: false, message: "This checkbox is required" };
}
return { valid: true };
},
});
constructor() {
super();
this.checked = false;
this.disabled = false;
this.indeterminate = false;
this.required = false;
this.name = "";
this.value = "on";
}
private getChecked(): boolean {
return this._formField.getValue();
}
private setChecked(newValue: boolean): void {
this._formField.setValue(newValue);
// Update aria attributes immediately since checked property doesn't change
// when buffering (the buffer is internal, not reflected to the property)
this._updateAriaAttributes();
}
override connectedCallback() {
// Set host attributes before super.connectedCallback() triggers rendering.
// Cannot be in the constructor — the custom element spec forbids
// setAttribute during construction.
if (!this.hasAttribute("role")) {
this.setAttribute("role", "checkbox");
}
if (!this.hasAttribute("exportparts")) {
this.setAttribute("exportparts", "checkbox,checkmark");
}
super.connectedCallback();
this._updateAriaAttributes();
// Bind initial checked value
this._checkedCellController.bind(this.checked, booleanSchema);
// Add event listeners to the host element to make entire component clickable
this.addEventListener("click", this._handleClick);
this.addEventListener("keydown", this._handleKeydown);
}
override firstUpdated() {
// Register with form after first render when context is available
this._formField.register(this.name);
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up event listeners
this.removeEventListener("click", this._handleClick);
this.removeEventListener("keydown", this._handleKeydown);
// Controllers handle form cleanup automatically via ReactiveController
}
override willUpdate(
changedProperties: Map,
) {
super.willUpdate(changedProperties);
// If the checked property itself changed (e.g., switched to a different cell)
if (changedProperties.has("checked")) {
// Bind the new cell first so getValue() returns the new value
this._checkedCellController.bind(this.checked, booleanSchema);
// Then clear buffer - this captures the new cell's value as baseline for reset/dirty
this._formField.clearBuffer();
}
}
override updated(
changedProperties: Map,
) {
super.updated(changedProperties);
if (
changedProperties.has("checked") ||
changedProperties.has("indeterminate") ||
changedProperties.has("disabled")
) {
this._updateAriaAttributes();
}
}
override render() {
const isChecked = this.getChecked();
const checkboxClasses = {
"checkbox": true,
"checked": isChecked && !this.indeterminate,
"indeterminate": this.indeterminate,
"disabled": this.disabled,
};
const classString = Object.entries(checkboxClasses)
.filter(([_, value]) => value)
.map(([key]) => key)
.join(" ");
return html`
`;
}
private _updateAriaAttributes() {
const isChecked = this.getChecked();
this.setAttribute(
"aria-checked",
this.indeterminate ? "mixed" : String(isChecked),
);
this.setAttribute("aria-disabled", String(this.disabled));
this.tabIndex = this.disabled ? -1 : 0;
}
private _handleClick(event: Event) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
// Toggle checked state
const oldChecked = this.getChecked();
this.setChecked(!oldChecked);
// Clear indeterminate state when clicked
if (this.indeterminate) {
this.indeterminate = false;
}
// Note: cf-change event is emitted by the cell controller's onChange callback
}
private _handleKeydown(event: KeyboardEvent) {
if (this.disabled) {
return;
}
// Handle Space key
if (event.key === " " || event.key === "Spacebar") {
event.preventDefault();
this._handleClick(event);
}
}
/**
* Focus the checkbox programmatically
*/
override focus(): void {
super.focus();
}
/**
* Blur the checkbox programmatically
*/
override blur(): void {
super.blur();
}
}