import { css, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { BaseElement } from "../../core/base-element.ts";
import { type Cell } from "@commontools/runner";
import { createBooleanCellController } from "../../core/cell-controller.ts";
/**
* CTCheckbox - Binary selection input with support for indeterminate state
*
* @element ct-checkbox
*
* @attr {boolean|Cell} checked - Whether the checkbox is checked (supports both plain boolean and Cell)
* @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 ct-change - Fired on change with detail: { checked, indeterminate }
*
* @example
* Accept terms
*
* @example
*
* Enable feature
*/
export class CTCheckbox extends BaseElement {
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 },
name: { type: String },
value: { type: String },
};
declare checked: Cell | boolean;
declare disabled: boolean;
declare indeterminate: boolean;
declare name: string;
declare value: string;
private _changeGroup = crypto.randomUUID();
private _checkedCellController = createBooleanCellController(this, {
timing: {
strategy: "immediate",
delay: 0,
},
changeGroup: this._changeGroup,
onChange: (newValue: boolean, _oldValue: boolean) => {
this.emit("ct-change", {
checked: newValue,
indeterminate: this.indeterminate,
});
},
});
constructor() {
super();
this.checked = false;
this.disabled = false;
this.indeterminate = false;
this.name = "";
this.value = "on";
}
private getChecked(): boolean {
return this._checkedCellController.getValue();
}
private setChecked(newValue: boolean): void {
this._checkedCellController.setValue(newValue);
}
override connectedCallback() {
super.connectedCallback();
// Make the element focusable
this.tabIndex = this.disabled ? -1 : 0;
this.setAttribute("role", "checkbox");
this._updateAriaAttributes();
// Bind initial checked value
this._checkedCellController.bind(this.checked);
// Add event listeners to the host element to make entire component clickable
this.addEventListener("click", this._handleClick);
this.addEventListener("keydown", this._handleKeydown);
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up event listeners
this.removeEventListener("click", this._handleClick);
this.removeEventListener("keydown", this._handleKeydown);
}
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 checked (Cell or plain) to the controller
this._checkedCellController.bind(this.checked);
}
}
override updated(
changedProperties: Map,
) {
super.updated(changedProperties);
if (changedProperties.has("disabled")) {
this.tabIndex = this.disabled ? -1 : 0;
}
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));
}
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: ct-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();
}
}
globalThis.customElements.define("ct-checkbox", CTCheckbox);