) {
+ super.willUpdate(changedProperties);
+
+ // If the value property itself changed (e.g., switched to a different cell)
+ if (changedProperties.has("value")) {
+ this._cellController.bind(this.value);
+ }
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has("name")) {
this.updateRadioNames();
}
- if (changedProperties.has("value")) {
- const oldValue = changedProperties.get("value") as string;
+ if (changedProperties.has("value") || changedProperties.has("items")) {
this.updateRadioSelection();
- if (oldValue !== this.value) {
- this.emit("ct-change", { value: this.value });
- }
}
if (changedProperties.has("disabled")) {
this.updateRadioDisabled();
}
+ if (changedProperties.has("theme")) {
+ applyThemeToElement(this, this.theme ?? defaultTheme);
+ }
}
override render() {
+ // If items are provided, render them directly
+ if (this.items && this.items.length > 0) {
+ return html`
+
+ ${this.items.map((item, index) => this._renderItem(item, index))}
+
+ `;
+ }
+
+ // Otherwise, use slot for ct-radio children
return html`
@@ -123,6 +218,89 @@ export class CTRadioGroup extends BaseElement {
`;
}
+ private _renderItem(item: RadioItem, index: number) {
+ const currentValue = this.getCurrentValue();
+ const isChecked = areLinksSame(currentValue, item.value);
+ const isDisabled = this.disabled || item.disabled;
+ const itemId = `radio-${this.name || "group"}-${index}`;
+
+ return html`
+
+ `;
+ }
+
+ private _uniqueId = Math.random().toString(36).substring(2, 9);
+
+ private _handleItemChange(item: RadioItem) {
+ if (this.disabled || item.disabled) return;
+ this._cellController.setValue(item.value);
+ }
+
+ private _handleItemKeydown = (event: KeyboardEvent) => {
+ const inputs = Array.from(
+ this.shadowRoot?.querySelectorAll('input[type="radio"]') || [],
+ ) as HTMLInputElement[];
+ const enabledInputs = inputs.filter((input) => !input.disabled);
+
+ if (enabledInputs.length === 0) return;
+
+ const currentIndex = enabledInputs.findIndex(
+ (input) => input === event.target,
+ );
+ let nextIndex = currentIndex;
+
+ const isHorizontal = this.orientation === "horizontal";
+ const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown";
+ const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp";
+
+ switch (event.key) {
+ case nextKey:
+ case (isHorizontal ? "ArrowDown" : "ArrowRight"):
+ event.preventDefault();
+ nextIndex = currentIndex === -1
+ ? 0
+ : (currentIndex + 1) % enabledInputs.length;
+ break;
+ case prevKey:
+ case (isHorizontal ? "ArrowUp" : "ArrowLeft"):
+ event.preventDefault();
+ nextIndex = currentIndex === -1
+ ? enabledInputs.length - 1
+ : (currentIndex - 1 + enabledInputs.length) % enabledInputs.length;
+ break;
+ default:
+ return;
+ }
+
+ // Focus and select the next radio
+ const nextInput = enabledInputs[nextIndex];
+ if (nextInput) {
+ nextInput.focus();
+ nextInput.click();
+ }
+ };
+
private handleSlotChange = () => {
this.updateRadioNames();
this.updateRadioSelection();
@@ -146,9 +324,12 @@ export class CTRadioGroup extends BaseElement {
private updateRadioSelection(): void {
const radios = this.getRadios();
+ const currentValue = this.getCurrentValue();
+
radios.forEach((radio) => {
const radioValue = radio.getAttribute("value");
- if (radioValue === this.value) {
+ const isSelected = areLinksSame(radioValue, currentValue);
+ if (isSelected) {
radio.setAttribute("checked", "");
(radio as any).checked = true;
} else {
@@ -176,33 +357,40 @@ export class CTRadioGroup extends BaseElement {
const radio = customEvent.detail.radio;
if (radio && radio.getAttribute("value")) {
- this.value = radio.getAttribute("value");
+ this._cellController.setValue(radio.getAttribute("value"));
}
};
private handleKeydown = (event: KeyboardEvent): void => {
+ // Only handle for slotted radios
+ if (this.items && this.items.length > 0) return;
+
const radios = Array.from(this.getRadios()) as HTMLElement[];
- const enabledRadios = radios.filter((radio) =>
- !radio.hasAttribute("disabled")
+ const enabledRadios = radios.filter(
+ (radio) => !radio.hasAttribute("disabled"),
);
if (enabledRadios.length === 0) return;
- const currentIndex = enabledRadios.findIndex((radio) =>
- radio === document.activeElement
+ const currentIndex = enabledRadios.findIndex(
+ (radio) => radio === document.activeElement,
);
let nextIndex = currentIndex;
+ const isHorizontal = this.orientation === "horizontal";
+ const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown";
+ const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp";
+
switch (event.key) {
- case "ArrowDown":
- case "ArrowRight":
+ case nextKey:
+ case (isHorizontal ? "ArrowDown" : "ArrowRight"):
event.preventDefault();
nextIndex = currentIndex === -1
? 0
: (currentIndex + 1) % enabledRadios.length;
break;
- case "ArrowUp":
- case "ArrowLeft":
+ case prevKey:
+ case (isHorizontal ? "ArrowUp" : "ArrowLeft"):
event.preventDefault();
nextIndex = currentIndex === -1
? enabledRadios.length - 1
@@ -221,25 +409,32 @@ export class CTRadioGroup extends BaseElement {
}
};
+ /**
+ * Get the current value from the cell controller
+ */
+ private getCurrentValue(): unknown {
+ return this._cellController.getValue();
+ }
+
/**
* Get the currently selected radio value
*/
- getValue(): string {
- return this.value;
+ getValue(): unknown {
+ return this.getCurrentValue();
}
/**
* Set the selected radio by value
*/
- setValue(value: string): void {
- this.value = value;
+ setValue(value: unknown): void {
+ this._cellController.setValue(value);
}
/**
* Clear the selection
*/
clear(): void {
- this.value = "";
+ this._cellController.setValue(undefined);
}
}
diff --git a/packages/ui/src/v2/components/ct-radio-group/index.ts b/packages/ui/src/v2/components/ct-radio-group/index.ts
index a8908ec486..6c175cd201 100644
--- a/packages/ui/src/v2/components/ct-radio-group/index.ts
+++ b/packages/ui/src/v2/components/ct-radio-group/index.ts
@@ -6,3 +6,4 @@ if (!customElements.get("ct-radio-group")) {
}
export { CTRadioGroup, radioGroupStyles };
+export type { RadioGroupOrientation, RadioItem } from "./ct-radio-group.ts";
diff --git a/packages/ui/src/v2/components/ct-radio-group/styles.ts b/packages/ui/src/v2/components/ct-radio-group/styles.ts
index 80380aee25..49cfdf39e9 100644
--- a/packages/ui/src/v2/components/ct-radio-group/styles.ts
+++ b/packages/ui/src/v2/components/ct-radio-group/styles.ts
@@ -5,9 +5,23 @@
export const radioGroupStyles = `
:host {
display: block;
-
+ box-sizing: border-box;
+
/* Default spacing values */
--spacing: 0.5rem;
+
+ /* Radio button styling */
+ --radio-size: 1rem;
+ --radio-border-color: var(--ct-theme-color-border, #e2e8f0);
+ --radio-checked-color: var(--ct-theme-color-primary, #0f172a);
+ --radio-background: var(--ct-theme-color-background, #ffffff);
+ --radio-focus-ring: var(--ct-theme-color-primary, #94a3b8);
+ }
+
+ *,
+ *::before,
+ *::after {
+ box-sizing: inherit;
}
:host([disabled]) {
@@ -21,10 +35,11 @@ export const radioGroupStyles = `
gap: var(--spacing, 0.5rem);
}
- /* Support for horizontal layout if needed */
+ /* Support for horizontal layout */
:host([orientation="horizontal"]) .radio-group {
flex-direction: row;
flex-wrap: wrap;
+ align-items: center;
}
/* Ensure proper spacing between radio buttons and their labels */
@@ -43,4 +58,110 @@ export const radioGroupStyles = `
:host([disabled]) ::slotted(label) {
cursor: not-allowed;
}
+
+ /* ========================================
+ Styles for items-based rendering
+ ======================================== */
+
+ .radio-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ user-select: none;
+ font-family: var(--ct-theme-font-family, inherit);
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ color: var(--ct-theme-color-text, #111827);
+ }
+
+ .radio-item.disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+
+ /* Hide native radio input but keep it accessible */
+ .radio-item input[type="radio"] {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+ }
+
+ /* Custom radio indicator */
+ .radio-indicator {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--radio-size, 1rem);
+ height: var(--radio-size, 1rem);
+ border: 1px solid var(--radio-border-color, #e2e8f0);
+ border-radius: 50%;
+ background-color: var(--radio-background, #ffffff);
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ flex-shrink: 0;
+ }
+
+ /* Radio dot */
+ .radio-dot {
+ width: calc(var(--radio-size, 1rem) / 2);
+ height: calc(var(--radio-size, 1rem) / 2);
+ border-radius: 50%;
+ background-color: var(--radio-checked-color, #0f172a);
+ opacity: 0;
+ transform: scale(0);
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ /* Checked state */
+ .radio-item.checked .radio-indicator {
+ border-color: var(--radio-checked-color, #0f172a);
+ }
+
+ .radio-item.checked .radio-dot {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ /* Focus state */
+ .radio-item input[type="radio"]:focus-visible + .radio-indicator {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ box-shadow:
+ 0 0 0 2px var(--radio-background, #fff),
+ 0 0 0 4px var(--radio-focus-ring, #94a3b8);
+ }
+
+ /* Hover state */
+ .radio-item:not(.disabled):hover .radio-indicator {
+ border-color: var(--radio-checked-color, #0f172a);
+ }
+
+ /* Animation for selection */
+ .radio-item.checked .radio-dot {
+ animation: radio-dot-animation 200ms ease-out;
+ }
+
+ @keyframes radio-dot-animation {
+ 0% {
+ transform: scale(0);
+ }
+ 50% {
+ transform: scale(1.2);
+ }
+ 100% {
+ transform: scale(1);
+ }
+ }
+
+ /* Label styling */
+ .radio-label {
+ flex: 1;
+ }
`;
diff --git a/packages/ui/src/v2/components/ct-textarea/ct-textarea.ts b/packages/ui/src/v2/components/ct-textarea/ct-textarea.ts
index b1e581ead8..f4cebcc670 100644
--- a/packages/ui/src/v2/components/ct-textarea/ct-textarea.ts
+++ b/packages/ui/src/v2/components/ct-textarea/ct-textarea.ts
@@ -9,14 +9,18 @@ import {
defaultTheme,
themeContext,
} from "../theme-context.ts";
+import { type Cell } from "@commontools/runner";
+import { createStringCellController } from "../../core/cell-controller.ts";
+
+export type TimingStrategy = "immediate" | "debounce" | "throttle" | "blur";
/**
- * CTTextarea - Multi-line text input with support for auto-resize and various states
+ * CTTextarea - Multi-line text input with support for auto-resize, various states, and reactive data binding
*
* @element ct-textarea
*
* @attr {string} placeholder - Placeholder text
- * @attr {string} value - Textarea value
+ * @attr {string|Cell} value - Textarea value (supports both plain string and Cell)
* @attr {boolean} disabled - Whether the textarea is disabled
* @attr {boolean} readonly - Whether the textarea is read-only
* @attr {boolean} required - Whether the textarea is required
@@ -25,12 +29,26 @@ import {
* @attr {number} cols - Number of visible text columns
* @attr {number} maxlength - Maximum number of characters allowed
* @attr {boolean} auto-resize - Whether the textarea automatically resizes to fit content
+ * @attr {string} timingStrategy - Input timing strategy: "immediate" | "debounce" | "throttle" | "blur"
+ * @attr {number} timingDelay - Delay in milliseconds for debounce/throttle (default: 300)
*
- * @fires ct-input - Fired on input with detail: { value, name }
- * @fires ct-change - Fired on change with detail: { value, name }
+ * @fires ct-input - Fired on input with detail: { value, oldValue, name }
+ * @fires ct-change - Fired on change with detail: { value, oldValue, name }
+ * @fires ct-focus - Fired on focus with detail: { value, name }
+ * @fires ct-blur - Fired on blur with detail: { value, name }
+ * @fires ct-keydown - Fired on keydown with detail: { key, value, shiftKey, ctrlKey, metaKey, altKey, name }
+ * @fires ct-submit - Fired on Ctrl/Cmd+Enter with detail: { value, name }
*
* @example
*
+ *
+ * @example
+ *
+ *
+ *
+ * @example
+ *
+ *
*/
export class CTTextarea extends BaseElement {
@@ -52,9 +70,11 @@ export class CTTextarea extends BaseElement {
autocomplete: { type: String },
resize: { type: String },
autoResize: { type: Boolean, attribute: "auto-resize" },
+ timingStrategy: { type: String, attribute: "timing-strategy" },
+ timingDelay: { type: Number, attribute: "timing-delay" },
};
declare placeholder: string;
- declare value: string;
+ declare value: Cell | string;
declare disabled: boolean;
declare readonly: boolean;
declare error: boolean;
@@ -70,6 +90,8 @@ export class CTTextarea extends BaseElement {
declare autocomplete: string;
declare resize: string;
declare autoResize: boolean;
+ declare timingStrategy: TimingStrategy;
+ declare timingDelay: number;
static override styles = css`
:host {
@@ -257,8 +279,13 @@ export class CTTextarea extends BaseElement {
declare theme?: CTTheme;
// Cache + initial setup
-
private _textarea: HTMLTextAreaElement | null = null;
+ private _cellController = createStringCellController(this, {
+ timing: {
+ strategy: "debounce",
+ delay: 300,
+ },
+ });
constructor() {
super();
@@ -279,6 +306,16 @@ export class CTTextarea extends BaseElement {
this.autocomplete = "off";
this.resize = "vertical";
this.autoResize = false;
+ this.timingStrategy = "debounce";
+ this.timingDelay = 300;
+ }
+
+ private getValue(): string {
+ return this._cellController.getValue();
+ }
+
+ private setValue(newValue: string): void {
+ this._cellController.setValue(newValue);
}
get textarea(): HTMLTextAreaElement | null {
@@ -298,6 +335,15 @@ export class CTTextarea extends BaseElement {
| HTMLTextAreaElement
| null;
+ // Bind the initial value to the cell controller
+ this._cellController.bind(this.value);
+
+ // Update timing options to match current properties
+ this._cellController.updateTimingOptions({
+ strategy: this.timingStrategy,
+ delay: this.timingDelay,
+ });
+
// Apply theme on mount
applyThemeToElement(this, this.theme ?? defaultTheme);
@@ -317,6 +363,22 @@ export class CTTextarea extends BaseElement {
) {
super.updated(changedProperties);
+ if (changedProperties.has("value")) {
+ // Bind the new value (Cell or plain) to the controller
+ this._cellController.bind(this.value);
+ }
+
+ // Update timing options if they changed
+ if (
+ changedProperties.has("timingStrategy") ||
+ changedProperties.has("timingDelay")
+ ) {
+ this._cellController.updateTimingOptions({
+ strategy: this.timingStrategy,
+ delay: this.timingDelay,
+ });
+ }
+
if (changedProperties.has("theme")) {
applyThemeToElement(this, this.theme ?? defaultTheme);
}
@@ -348,7 +410,7 @@ export class CTTextarea extends BaseElement {
class="${this.error ? "error" : ""}"
style="${resizeStyle}"
placeholder="${ifDefined(this.placeholder || undefined)}"
- .value="${this.value}"
+ .value="${this.getValue()}"
?disabled="${this.disabled}"
?readonly="${this.readonly}"
?required="${this.required}"
@@ -372,8 +434,8 @@ export class CTTextarea extends BaseElement {
private _handleInput(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
- const oldValue = this.value;
- this.value = textarea.value;
+ const oldValue = this.getValue();
+ this.setValue(textarea.value);
// Auto-resize if enabled
if (this.autoResize) {
@@ -384,47 +446,54 @@ export class CTTextarea extends BaseElement {
this.emit("ct-input", {
value: textarea.value,
oldValue,
+ name: this.name,
});
}
private _handleChange(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
- const oldValue = this.value;
- this.value = textarea.value;
+ const oldValue = this.getValue();
// Emit custom change event
this.emit("ct-change", {
value: textarea.value,
oldValue,
+ name: this.name,
});
}
private _handleFocus(_event: Event) {
+ this._cellController.onFocus();
this.emit("ct-focus", {
- value: this.value,
+ value: this.getValue(),
+ name: this.name,
});
}
private _handleBlur(_event: Event) {
+ this._cellController.onBlur();
this.emit("ct-blur", {
- value: this.value,
+ value: this.getValue(),
+ name: this.name,
});
}
private _handleKeyDown(event: KeyboardEvent) {
this.emit("ct-keydown", {
key: event.key,
- value: this.value,
+ value: this.getValue(),
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altKey: event.altKey,
+ name: this.name,
});
// Special handling for Enter key with modifiers
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
this.emit("ct-submit", {
- value: this.value,
+ value: this.getValue(),
+ name: this.name,
});
}
}
diff --git a/packages/ui/src/v2/index.ts b/packages/ui/src/v2/index.ts
index 79535ca21c..acbd1bb93f 100644
--- a/packages/ui/src/v2/index.ts
+++ b/packages/ui/src/v2/index.ts
@@ -21,6 +21,7 @@ export * from "./components/ct-accordion/index.ts";
export * from "./components/ct-accordion-item/index.ts";
export * from "./components/ct-heading/index.ts";
export * from "./components/ct-alert/index.ts";
+export * from "./components/ct-autocomplete/index.ts";
export * from "./components/ct-autolayout/index.ts";
export * from "./components/ct-aspect-ratio/index.ts";
export * from "./components/ct-audio-visualizer/index.ts";