Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions docs/common/PATTERNS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,105 @@ const activeItems = computed(() => items.filter(item => !item.done));
| Event handler error | "'onclick' does not exist" | Change to camelCase: `onClick` |
| Conditional rendering | Element doesn't show/hide | Use `ifElse()` not ternary |

## Async Operations and Pending State

CommonTools provides built-in functions for async operations. All return a consistent response structure:

```typescript
{
pending: boolean, // true while operation is in progress
result: T | undefined, // successful result (undefined while pending or on error)
error: any | undefined, // error (undefined while pending or on success)
}
```

This applies to: `fetchData`, `generateText`, `generateObject`, `compileAndRun`.

### Visualizing Pending State with ct-loader

Use `<ct-loader>` to show loading state:

```tsx
const data = fetchData({ url, mode: "json" });

return {
[UI]: (
<div>
{ifElse(
data.pending,
<span><ct-loader size="sm" /> Loading...</span>,
ifElse(
data.error,
<ct-alert variant="error">Error: {data.error}</ct-alert>,
<pre>{JSON.stringify(data.result, null, 2)}</pre>
)
)}
</div>
),
};
```

### With Elapsed Time

For long-running operations, show elapsed time:

```tsx
{ifElse(
response.pending,
<span><ct-loader show-elapsed /> Generating...</span>,
<div>{response.result}</div>
)}
```

### With Stop Button

For cancellable operations (like LLM generation), add a stop button:

```tsx
const { result, pending, cancel } = generateText({ prompt });

{ifElse(
pending,
<span><ct-loader show-elapsed show-stop onct-stop={cancel} /> Generating...</span>,
<div>{result}</div>
)}
```

### Inline Per-Item Loading

For per-item async operations in lists:

```tsx
{items.map((item) => {
const summary = generateText({ prompt: `Summarize: ${item.content}` });

return (
<div>
<h3>{item.title}</h3>
{ifElse(
summary.pending,
<span><ct-loader size="sm" /> Summarizing...</span>,
<p>{summary.result}</p>
)}
</div>
);
})}
```

### Disable Actions While Pending

Prevent user actions during async operations:

```tsx
<ct-button disabled={analysis.pending} onClick={regenerate}>
{ifElse(
analysis.pending,
<span><ct-loader size="sm" /> Analyzing...</span>,
"Analyze"
)}
</ct-button>
```

## Summary

**Level 1 patterns:**
Expand Down
44 changes: 44 additions & 0 deletions packages/html/src/jsx.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2862,7 +2862,9 @@ interface CTOutlinerElement extends CTHTMLElement {}
interface CTCellLinkElement extends CTHTMLElement {}
interface CTListElement extends CTHTMLElement {}
interface CTListItemElement extends CTHTMLElement {}
interface CTLoaderElement extends CTHTMLElement {}
interface CTInputElement extends CTHTMLElement {}
interface CTFileInputElement extends CTHTMLElement {}
interface CTImageInputElement extends CTHTMLElement {}
interface CTInputLegacyElement extends CTHTMLElement {}
interface CTCheckboxElement extends CTHTMLElement {}
Expand Down Expand Up @@ -3181,6 +3183,14 @@ interface CTListItemAttributes<T> extends CTHTMLAttributes<T> {
"onct-activate"?: any;
}

interface CTLoaderAttributes<T> extends CTHTMLAttributes<T> {
"size"?: "sm" | "md" | "lg";
"show-elapsed"?: boolean;
"show-stop"?: boolean;
/** Fired when stop button is clicked */
"onct-stop"?: EventHandler<{}>;
}

interface CTFabAttributes<T> extends CTHTMLAttributes<T> {
"expanded"?: boolean;
"variant"?: "default" | "primary";
Expand Down Expand Up @@ -3238,6 +3248,32 @@ interface CTInputLegacyAttributes<T> extends CTHTMLAttributes<T> {
"customStyle"?: string;
}

interface CTFileInputAttributes<T> extends CTHTMLAttributes<T> {
"multiple"?: boolean;
"maxFiles"?: number;
"accept"?: string;
"buttonText"?: string;
"variant"?:
| "default"
| "primary"
| "secondary"
| "outline"
| "ghost"
| "link"
| "destructive";
"size"?: "default" | "sm" | "lg" | "icon";
"showPreview"?: boolean;
"previewSize"?: "sm" | "md" | "lg";
"removable"?: boolean;
"disabled"?: boolean;
"maxSizeBytes"?: number;
"files"?: any[]; // FileData[]
"$files"?: any; // CellLike<FileData[]>
"onct-change"?: EventHandler<any>;
"onct-remove"?: EventHandler<any>;
"onct-error"?: EventHandler<any>;
}

interface CTImageInputAttributes<T> extends CTHTMLAttributes<T> {
"multiple"?: boolean;
"maxImages"?: number;
Expand Down Expand Up @@ -3794,10 +3830,18 @@ declare global {
CTListItemAttributes<CTListItemElement>,
CTListItemElement
>;
"ct-loader": CTDOM.DetailedHTMLProps<
CTLoaderAttributes<CTLoaderElement>,
CTLoaderElement
>;
"ct-input": CTDOM.DetailedHTMLProps<
CTInputAttributes<CTInputElement>,
CTInputElement
>;
"ct-file-input": CTDOM.DetailedHTMLProps<
CTFileInputAttributes<CTFileInputElement>,
CTFileInputElement
>;
"ct-image-input": CTDOM.DetailedHTMLProps<
CTImageInputAttributes<CTImageInputElement>,
CTImageInputElement
Expand Down
31 changes: 20 additions & 11 deletions packages/patterns/llm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,26 @@ export default recipe<LLMTestInput>(({ title }) => {
</ct-cell-context>

<ct-cell-context $cell={llmResponse}>
{derive(llmResponse.result, (r) =>
r
? (
<div>
<h3>LLM Response:</h3>
<pre>
{r}
</pre>
</div>
)
: null)}
{derive(
[llmResponse.pending, llmResponse.result],
([pending, r]) =>
pending
? (
<div>
<ct-loader show-elapsed /> Thinking...
</div>
)
: r
? (
<div>
<h3>LLM Response:</h3>
<pre>
{r}
</pre>
</div>
)
: null,
)}
</ct-cell-context>
</div>
),
Expand Down
89 changes: 82 additions & 7 deletions packages/ui/src/v2/components/ct-card/ct-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { BaseElement } from "../../core/base-element.ts";
* <p slot="content">Card content goes here</p>
* <ct-button slot="footer">Action</ct-button>
* </ct-card>
*
* Uses JS to detect empty slots (CSS :has() can't distinguish assigned vs fallback content).
*/

export class CTCard extends BaseElement {
Expand Down Expand Up @@ -70,8 +72,13 @@ export class CTCard extends BaseElement {
padding-bottom: 0;
}

/* Hide header if it has no slotted content */
.card-header:not(:has(*)) {
/* When header is the only section, add bottom padding */
.card-header:not(.empty):has(+ .card-content.empty) {
padding-bottom: 1.5rem;
}

/* Hide header if empty (controlled by JS via .empty class) */
.card-header.empty {
display: none;
padding: 0;
}
Expand All @@ -84,7 +91,8 @@ export class CTCard extends BaseElement {
gap: 1rem;
}

.card-title-wrapper:not(:has([slot])) {
/* Hide title wrapper if empty (controlled by JS via .empty class) */
.card-title-wrapper.empty {
display: none;
}

Expand All @@ -110,8 +118,8 @@ export class CTCard extends BaseElement {
padding: 1.5rem;
}

/* Hide content if it has no slotted content */
.card-content:not(:has(*)) {
/* Hide content if empty (controlled by JS via .empty class) */
.card-content.empty {
display: none;
padding: 0;
}
Expand All @@ -122,8 +130,8 @@ export class CTCard extends BaseElement {
padding-top: 0;
}

/* Hide footer if it has no slotted content */
.card-footer:not(:has(*)) {
/* Hide footer if empty (controlled by JS via .empty class) */
.card-footer.empty {
display: none;
padding: 0;
}
Expand Down Expand Up @@ -157,6 +165,16 @@ export class CTCard extends BaseElement {
}
}

override firstUpdated() {
// Set up slot change listeners to detect empty slots
this.shadowRoot?.querySelectorAll("slot").forEach((slot) => {
slot.addEventListener("slotchange", () => this._updateEmptyStates());
});

// Initial check for empty states
this._updateEmptyStates();
}

override disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("click", this._handleClick);
Expand Down Expand Up @@ -204,6 +222,63 @@ export class CTCard extends BaseElement {
`;
}

/** Check if slot has real content (not just whitespace) */
private _slotHasContent(slot: HTMLSlotElement | null): boolean {
if (!slot) return false;
return slot.assignedNodes().some((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent?.trim() !== "";
}
return true;
});
}

/** Update empty state classes based on slot content */
private _updateEmptyStates(): void {
const getSlot = (name: string) =>
this.shadowRoot?.querySelector(`slot[name="${name}"]`) as
| HTMLSlotElement
| null;

const headerSlot = getSlot("header");
const contentSlot = getSlot("content");
const defaultSlot = contentSlot?.querySelector("slot:not([name])") as
| HTMLSlotElement
| null;
const footerSlot = getSlot("footer");
const titleSlot = getSlot("title");
const actionSlot = getSlot("action");
const descriptionSlot = getSlot("description");

const hasHeader = this._slotHasContent(headerSlot);
const hasContent = this._slotHasContent(contentSlot) ||
this._slotHasContent(defaultSlot);
const hasFooter = this._slotHasContent(footerSlot);
const hasTitle = this._slotHasContent(titleSlot);
const hasAction = this._slotHasContent(actionSlot);
const hasDescription = this._slotHasContent(descriptionSlot);

const showHeader = hasHeader || hasTitle || hasAction || hasDescription;
const showTitleWrapper = hasTitle || hasAction;

this.shadowRoot?.querySelector(".card-header")?.classList.toggle(
"empty",
!showHeader,
);
this.shadowRoot?.querySelector(".card-content")?.classList.toggle(
"empty",
!hasContent,
);
this.shadowRoot?.querySelector(".card-footer")?.classList.toggle(
"empty",
!hasFooter,
);
this.shadowRoot?.querySelector(".card-title-wrapper")?.classList.toggle(
"empty",
!showTitleWrapper,
);
}

private _handleClick = (_event: Event): void => {
if (!this.clickable) return;

Expand Down
Loading
Loading