import { css, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { BaseElement } from "../../core/base-element.ts";
import { consume } from "@lit/context";
import {
applyThemeToElement,
type CTTheme,
defaultTheme,
themeContext,
} from "../theme-context.ts";
import { areLinksSame, type Cell } from "@commontools/runner";
import { createCellController } from "../../core/cell-controller.ts";
/**
* CTSelect – Dropdown/select component that accepts an array of generic JS objects
*
* @element ct-select
*
* @attr {boolean} disabled – Whether the select is disabled
* @attr {boolean} multiple – Enable multiple selection
* @attr {boolean} required – Whether the field is required
* @attr {number} size – Number of visible options (native size attribute)
* @attr {string} name – Name used when participating in a form
* @attr {string} placeholder – Placeholder text rendered as a disabled option
*
* @prop {Array} items – Data used to generate options
* @prop {Cell|Cell|unknown|unknown[]} value – Selected value(s) - supports both Cell and plain values
*
* @fires ct-change – detail: { value, oldValue, items }
* @fires change – detail: { value, oldValue, items }
* @fires ct-focus
* @fires ct-blur
*
* @example
*
*
*/
export interface SelectItem {
/** Text shown to the user */
label: string;
/** Arbitrary JS value returned when this option is selected */
value: unknown;
/** Disabled state for this option */
disabled?: boolean;
/**
* Optional grouping key. When provided, options with
* identical `group` values will be wrapped in an .
*/
group?: string;
}
export class CTSelect extends BaseElement {
/* ---------- Styles ---------- */
static override styles = [
BaseElement.baseStyles,
css`
:host {
display: inline-block;
width: 100%;
box-sizing: border-box;
}
select {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--ct-theme-color-text, #111827);
background-color: var(--ct-theme-color-background, #ffffff);
border: 1px solid var(--ct-theme-color-border, #e5e7eb);
border-radius: var(
--ct-theme-border-radius,
var(--ct-border-radius-md, 0.375rem)
);
transition: all var(--ct-theme-animation-duration, 150ms)
var(--ct-transition-timing-ease);
font-family: var(--ct-theme-font-family, inherit);
appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' xmlns='http://www.w3.org/2000/svg' fill='%23666666'%3E%3Cpath d='M6 8 0 0h12L6 8Z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 12px 8px;
}
/* Disabled */
select:disabled {
cursor: not-allowed;
opacity: 0.5;
background-color: var(--ct-theme-color-surface, #f1f5f9);
}
/* Focus */
select:focus {
outline: none;
border-color: var(--ct-theme-color-primary, #3b82f6);
box-shadow: 0 0 0 3px
var(--ct-theme-color-primary, rgba(59, 130, 246, 0.15));
}
/* Hover */
select:hover:not(:disabled):not(:focus) {
border-color: var(--ct-theme-color-border, #d1d5db);
}
/* Arrow removed on multi */
:host([multiple]) select {
background-image: none;
}
`,
];
/* ---------- Refs & helpers ---------- */
private _changeGroup = crypto.randomUUID();
private _select!: HTMLSelectElement;
/** Mapping from stringified option key -> SelectItem */
private _keyMap = new Map();
/* ---------- Cell controller for value binding ---------- */
private _cellController = createCellController(this, {
timing: { strategy: "immediate" }, // Select changes should be immediate
changeGroup: this._changeGroup,
onChange: (newValue, oldValue) => {
// Sync cell value changes to DOM
this.applyValueToDom();
// Emit change events
this.emit("ct-change", {
value: newValue,
oldValue,
items: this.items,
});
this.emit("change", {
value: newValue,
oldValue,
items: this.items,
});
},
});
/* ---------- Reactive properties ---------- */
static override properties = {
disabled: { type: Boolean, reflect: true },
multiple: { type: Boolean, reflect: true },
required: { type: Boolean, reflect: true },
size: { type: Number },
name: { type: String },
placeholder: { type: String },
// Non-attribute properties
items: { attribute: false },
value: { attribute: false },
};
declare disabled: boolean;
declare multiple: boolean;
declare required: boolean;
declare size: number;
declare name: string;
declare placeholder: string;
declare items: SelectItem[];
declare value: Cell | Cell | unknown | unknown[];
constructor() {
super();
this.disabled = false;
this.multiple = false;
this.required = false;
this.size = 0;
this.name = "";
this.placeholder = "";
this.items = [];
this.value = this.multiple ? [] : undefined;
}
/* ---------- Lifecycle ---------- */
override firstUpdated() {
this._select = this.shadowRoot!.querySelector(
"select",
) as HTMLSelectElement;
// Initialize cell controller binding
this._cellController.bind(this.value);
this.applyValueToDom();
// Apply theme on first render
applyThemeToElement(this, this.theme ?? defaultTheme);
}
override willUpdate(changedProperties: Map) {
super.willUpdate(changedProperties);
// If the value property itself changed (e.g., switched to a different cell)
if (changedProperties.has("value")) {
// Bind the new value (Cell or plain) to the controller
this._cellController.bind(this.value);
}
}
override updated(changed: Map) {
if (changed.has("items")) {
// Rebuild key map each time items array changes
this._buildKeyMap();
}
if (changed.has("value") || changed.has("items")) {
this.applyValueToDom();
}
if (changed.has("theme")) {
applyThemeToElement(this, this.theme ?? defaultTheme);
}
}
// Theme consumption
@consume({ context: themeContext, subscribe: true })
@property({ attribute: false })
declare theme?: CTTheme;
/* ---------- Render ---------- */
override render() {
return html`
this.emit("ct-focus")}"
@blur="${() => this.emit("ct-blur")}"
part="select"
>
${this._renderPlaceholder()} ${this._renderOptions()}
`;
}
private _renderPlaceholder() {
const currentValue = this.getCurrentValue();
const hasSelection =
(this.multiple ? (currentValue as unknown[])?.length : currentValue) ??
false;
// Use placeholder if provided, otherwise use "-" (no selection)
const placeholderText = this.placeholder || "-";
return html`
${placeholderText}
`;
}
private _renderOptions() {
if (!this.items?.length) return nothing;
// Group items by `group` key
const groups = new Map();
this.items.forEach((item) => {
const key = item.group;
const arr = groups.get(key) ?? [];
arr.push(item);
groups.set(key, arr);
});
const renderItem = (item: SelectItem, index: number) => {
const optionKey = this._makeKey(item, index);
return html`
${item.label}
`;
};
const templates: unknown[] = [];
let runningIndex = 0;
groups.forEach((items, group) => {
if (group) {
templates.push(html`
${items.map((i) => renderItem(i, runningIndex++))}
`);
} else {
templates.push(...items.map((i) => renderItem(i, runningIndex++)));
}
});
// Build key map once per render
this._buildKeyMap();
return templates;
}
/* ---------- Events ---------- */
private _onChange(e: Event) {
const select = e.target as HTMLSelectElement;
const _oldValue = this.getCurrentValue();
let newValue: unknown | unknown[];
if (this.multiple) {
const selectedKeys = Array.from(select.selectedOptions).map(
(o) => o.value,
);
newValue = selectedKeys.map((k) => this._keyMap.get(k)!.value);
} else {
const optKey = select.value;
newValue = this._keyMap.get(optKey)?.value;
}
// Always update through cell controller
this._cellController.setValue(newValue);
}
/* ---------- Public API ---------- */
override focus() {
this._select?.focus();
}
override blur() {
this._select?.blur();
}
checkValidity() {
return this._select?.checkValidity() ?? true;
}
reportValidity() {
return this._select?.reportValidity() ?? true;
}
/* ---------- Internal helpers ---------- */
private _makeKey(_item: SelectItem, index: number) {
// Unique deterministic key for each option
return `${index}`;
}
private _buildKeyMap() {
this._keyMap.clear();
this.items?.forEach((item, index) => {
this._keyMap.set(this._makeKey(item, index), item);
});
}
/**
* Get the current value from the cell controller
*/
private getCurrentValue(): unknown | unknown[] {
return this._cellController.getValue();
}
/**
* After any update, ensure DOM option selection state
* matches the current value.
*/
private applyValueToDom() {
if (!this._select) return;
const currentValue = this.getCurrentValue();
if (this.multiple) {
const values = (currentValue as unknown[] | undefined) ?? [];
Array.from(this._select.options).forEach((opt) => {
const item = this._keyMap.get(opt.value);
opt.selected = item
? values.some((v) => areLinksSame(v, item.value))
: false;
});
} else {
const val = currentValue;
const matchKey = [...this._keyMap.entries()].find(
([, item]) => areLinksSame(item.value, val),
)?.[0];
this._select.value = matchKey ?? "";
}
}
}
globalThis.customElements.define("ct-select", CTSelect);