From dafab61d1df709f9019e981eb916f5d83c3bc496 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:37:28 +1000 Subject: [PATCH 1/3] Extract `ct-loader` and document `pending` pattern (#2186) * Extract `ct-loader` and document `pending` pattern * Format pass --- docs/common/PATTERNS.md | 99 ++++++ packages/html/src/jsx.d.ts | 13 + packages/patterns/llm.tsx | 31 +- .../src/v2/components/ct-loader/ct-loader.ts | 287 ++++++++++++++++++ .../ui/src/v2/components/ct-loader/index.ts | 8 + .../src/v2/components/ct-render/ct-render.ts | 18 +- packages/ui/src/v2/index.ts | 1 + 7 files changed, 430 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/v2/components/ct-loader/ct-loader.ts create mode 100644 packages/ui/src/v2/components/ct-loader/index.ts diff --git a/docs/common/PATTERNS.md b/docs/common/PATTERNS.md index 2958e5a201..b7b066e079 100644 --- a/docs/common/PATTERNS.md +++ b/docs/common/PATTERNS.md @@ -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 `` to show loading state: + +```tsx +const data = fetchData({ url, mode: "json" }); + +return { + [UI]: ( +
+ {ifElse( + data.pending, + Loading..., + ifElse( + data.error, + Error: {data.error}, +
{JSON.stringify(data.result, null, 2)}
+ ) + )} +
+ ), +}; +``` + +### With Elapsed Time + +For long-running operations, show elapsed time: + +```tsx +{ifElse( + response.pending, + Generating..., +
{response.result}
+)} +``` + +### With Stop Button + +For cancellable operations (like LLM generation), add a stop button: + +```tsx +const { result, pending, cancel } = generateText({ prompt }); + +{ifElse( + pending, + Generating..., +
{result}
+)} +``` + +### Inline Per-Item Loading + +For per-item async operations in lists: + +```tsx +{items.map((item) => { + const summary = generateText({ prompt: `Summarize: ${item.content}` }); + + return ( +
+

{item.title}

+ {ifElse( + summary.pending, + Summarizing..., +

{summary.result}

+ )} +
+ ); +})} +``` + +### Disable Actions While Pending + +Prevent user actions during async operations: + +```tsx + + {ifElse( + analysis.pending, + Analyzing..., + "Analyze" + )} + +``` + ## Summary **Level 1 patterns:** diff --git a/packages/html/src/jsx.d.ts b/packages/html/src/jsx.d.ts index a000d574ad..6b220f8b32 100644 --- a/packages/html/src/jsx.d.ts +++ b/packages/html/src/jsx.d.ts @@ -2862,6 +2862,7 @@ 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 CTImageInputElement extends CTHTMLElement {} interface CTInputLegacyElement extends CTHTMLElement {} @@ -3181,6 +3182,14 @@ interface CTListItemAttributes extends CTHTMLAttributes { "onct-activate"?: any; } +interface CTLoaderAttributes extends CTHTMLAttributes { + "size"?: "sm" | "md" | "lg"; + "show-elapsed"?: boolean; + "show-stop"?: boolean; + /** Fired when stop button is clicked */ + "onct-stop"?: EventHandler<{}>; +} + interface CTFabAttributes extends CTHTMLAttributes { "expanded"?: boolean; "variant"?: "default" | "primary"; @@ -3794,6 +3803,10 @@ declare global { CTListItemAttributes, CTListItemElement >; + "ct-loader": CTDOM.DetailedHTMLProps< + CTLoaderAttributes, + CTLoaderElement + >; "ct-input": CTDOM.DetailedHTMLProps< CTInputAttributes, CTInputElement diff --git a/packages/patterns/llm.tsx b/packages/patterns/llm.tsx index 200c187d43..fb0a3d04cd 100644 --- a/packages/patterns/llm.tsx +++ b/packages/patterns/llm.tsx @@ -69,17 +69,26 @@ export default recipe(({ title }) => { - {derive(llmResponse.result, (r) => - r - ? ( -
-

LLM Response:

-
-                    {r}
-                  
-
- ) - : null)} + {derive( + [llmResponse.pending, llmResponse.result], + ([pending, r]) => + pending + ? ( +
+ Thinking... +
+ ) + : r + ? ( +
+

LLM Response:

+
+                      {r}
+                    
+
+ ) + : null, + )}
), diff --git a/packages/ui/src/v2/components/ct-loader/ct-loader.ts b/packages/ui/src/v2/components/ct-loader/ct-loader.ts new file mode 100644 index 0000000000..8c14dc69de --- /dev/null +++ b/packages/ui/src/v2/components/ct-loader/ct-loader.ts @@ -0,0 +1,287 @@ +/** + * @fileoverview UI Loader Component - Spinning loading indicator + * + * @module ct-loader + * @description + * A simple inline spinner for visualizing pending async operations. + * Optionally displays elapsed time and a stop/cancel button. + * + * @example + * ```html + * + * + * + * + * + * + * + * + * + * + * Loading + * ``` + */ + +import { css, html } from "lit"; +import { BaseElement } from "../../core/base-element.ts"; + +export type LoaderSize = "sm" | "md" | "lg"; + +/** + * CTLoader displays a spinning loading indicator. + * + * @tag ct-loader + * @extends BaseElement + * + * @property {LoaderSize} size - Size variant: "sm" (12px), "md" (24px), "lg" (48px) + * @property {boolean} showElapsed - Whether to display elapsed time + * @property {boolean} showStop - Whether to display stop button + * + * @fires ct-stop - Fired when stop button is clicked + * + * @csspart spinner - The spinning circle SVG + * @csspart elapsed - The elapsed time text + * @csspart stop - The stop button + */ +export class CTLoader extends BaseElement { + static override styles = css` + :host { + display: inline-flex; + align-items: center; + vertical-align: middle; + gap: 0.375rem; + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + .spinner { + animation: spin 0.8s linear infinite; + } + + /* Size variants: sm=12px, md=24px, lg=48px */ + :host([size="sm"]) .spinner { + width: 12px; + height: 12px; + } + + :host([size="md"]) .spinner, + :host(:not([size])) .spinner { + width: 24px; + height: 24px; + } + + :host([size="lg"]) .spinner { + width: 48px; + height: 48px; + } + + .track { + stroke: var(--ct-color-border, #e0e0e0); + } + + .arc { + stroke: var(--ct-color-primary, #000); + stroke-linecap: round; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .elapsed { + font-size: 0.75rem; + color: var(--ct-color-text-muted, #666); + font-variant-numeric: tabular-nums; + } + + .stop-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 2px; + background: transparent; + color: var(--ct-color-text-muted, #666); + cursor: pointer; + } + + .stop-button:hover { + background: var(--ct-color-surface, #f0f0f0); + color: var(--ct-color-error, #dc2626); + } + + .stop-button svg { + width: 10px; + height: 10px; + } + + /* Screen reader only */ + .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; + } + + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + } + `; + + static override properties = { + size: { type: String, reflect: true }, + showElapsed: { type: Boolean, attribute: "show-elapsed" }, + showStop: { type: Boolean, attribute: "show-stop" }, + }; + + declare size: LoaderSize; + declare showElapsed: boolean; + declare showStop: boolean; + + private _startTime: number = 0; + private _elapsedMs: number = 0; + private _animationFrame: number | null = null; + + constructor() { + super(); + this.size = "md"; + this.showElapsed = false; + this.showStop = false; + } + + override connectedCallback(): void { + super.connectedCallback(); + this._startTime = Date.now(); + if (this.showElapsed) { + this._startTimer(); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._stopTimer(); + } + + override updated(changedProperties: Map): void { + if (changedProperties.has("showElapsed")) { + if (this.showElapsed) { + this._startTimer(); + } else { + this._stopTimer(); + } + } + } + + private _startTimer(): void { + if (this._animationFrame !== null) return; + + const tick = () => { + this._elapsedMs = Date.now() - this._startTime; + this.requestUpdate(); + this._animationFrame = requestAnimationFrame(tick); + }; + this._animationFrame = requestAnimationFrame(tick); + } + + private _stopTimer(): void { + if (this._animationFrame !== null) { + cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + } + } + + private _formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } + + private _handleStop(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.emit("ct-stop", {}); + } + + override render() { + return html` + + + + + + ${this.showElapsed + ? html` + ${this._formatElapsed( + this._elapsedMs, + )} + ` + : null} ${this.showStop + ? html` + + ` + : null} + + Loading + `; + } + + /** Reset the elapsed timer */ + resetTimer(): void { + this._startTime = Date.now(); + this._elapsedMs = 0; + } +} + +globalThis.customElements.define("ct-loader", CTLoader); diff --git a/packages/ui/src/v2/components/ct-loader/index.ts b/packages/ui/src/v2/components/ct-loader/index.ts new file mode 100644 index 0000000000..57bf550d29 --- /dev/null +++ b/packages/ui/src/v2/components/ct-loader/index.ts @@ -0,0 +1,8 @@ +import { CTLoader, LoaderSize } from "./ct-loader.ts"; + +if (!customElements.get("ct-loader")) { + customElements.define("ct-loader", CTLoader); +} + +export { CTLoader }; +export type { LoaderSize }; diff --git a/packages/ui/src/v2/components/ct-render/ct-render.ts b/packages/ui/src/v2/components/ct-render/ct-render.ts index a428767b91..08ab43fd93 100644 --- a/packages/ui/src/v2/components/ct-render/ct-render.ts +++ b/packages/ui/src/v2/components/ct-render/ct-render.ts @@ -4,6 +4,7 @@ import { render } from "@commontools/html"; import type { Cell } from "@commontools/runner"; import { getRecipeIdFromCharm } from "@commontools/charm"; import { type VNode } from "@commontools/runner"; +import "../ct-loader/ct-loader.ts"; // Set to true to enable debug logging const DEBUG_LOGGING = false; @@ -38,21 +39,6 @@ export class CTRender extends BaseElement { width: 100%; height: 100%; } - - .spinner { - width: 20px; - height: 20px; - border: 2px solid var(--ct-color-border, #e0e0e0); - border-top-color: var(--ct-color-primary, #000); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } - } `; static override properties = { @@ -83,7 +69,7 @@ export class CTRender extends BaseElement { ${!this._hasRendered ? html`
-
+
` : null} diff --git a/packages/ui/src/v2/index.ts b/packages/ui/src/v2/index.ts index cdf7cd6d04..bfec1f92e5 100644 --- a/packages/ui/src/v2/index.ts +++ b/packages/ui/src/v2/index.ts @@ -73,6 +73,7 @@ export * from "./components/ct-tab-panel/index.ts"; export * from "./components/ct-tabs/index.ts"; export * from "./components/ct-list/index.ts"; export * from "./components/ct-list-item/index.ts"; +export * from "./components/ct-loader/index.ts"; export * from "./components/ct-markdown/index.ts"; export * from "./components/ct-tags/index.ts"; export * from "./components/ct-table/index.ts"; From 1487939bcb7397eec0165d34ff751afc1c48930c Mon Sep 17 00:00:00 2001 From: Alex Komoroske Date: Mon, 1 Dec 2025 22:21:02 -0800 Subject: [PATCH 2/3] Fix ct-card empty section whitespace and title/description/action slots (#2152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix ct-card empty section detection with JS-based slot monitoring ## Problem ct-card was showing excessive vertical whitespace when header/footer slots were empty. The previous CSS-only fix (commit 7696b91) using `:not(:has(*))` appeared to work in testing but failed in practice. ## Root Cause The `:not(:has(*))` selector cannot distinguish between: 1. A slot with no assigned content (should hide) 2. A slot showing fallback content (the slot always has children) Even when nothing is slotted, the `` contains fallback child elements (card-title-wrapper div, nested slots), making `:has(*)` always return true. Additionally, for content detection, we were only checking the named `slot[name="content"]` but missing the default `` inside it, which is where content goes when using ``. ## Solution Implemented JavaScript-based slot detection: 1. Listen to `slotchange` events on all slots 2. Check `assignedNodes()` to detect actual slotted content 3. Add/remove `.empty` class which CSS uses to hide sections 4. For content, check BOTH named slot AND default slot ## Testing Verified with food-recipe pattern in Playwright: - Empty header/footer: `display: none` ✓ - Content visible: `display: block` ✓ - Spacing: 25px on all sides (1px border + 24px padding) ✓ - Top/bottom now matches left/right ✓ Before: 49px top, 73px bottom (asymmetric) After: 25px all sides (symmetric) Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Fix ct-card title/description/action slots not displaying The CSS rule was trying to hide the wrapper when empty, but can't detect slot assignment. The slotted elements (with slot="title", etc.) are in the light DOM, not in the shadow DOM where the wrapper lives. Solution: Use same JS-based detection approach for title-wrapper visibility. Check title/action/description slots with assignedNodes() and toggle .empty class on both the title-wrapper and header elements. This fixes Test 5 in the comprehensive test suite where title/description/ action slots were present but not displaying. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Fix ct-card whitespace detection and header-only spacing Addresses cubic's PR feedback about whitespace-only text nodes being counted as content. Changes: - Add hasContent() helper that filters whitespace-only text nodes and empty elements - Apply filtering consistently to all slot checks (header, content, footer, title, action, description) - Fix vertical spacing for header-only cards by adding bottom padding when content section is empty This prevents formatting whitespace in HTML from incorrectly showing empty card sections. 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Fix hasContent to not hide childless elements Addresses cubic's feedback that elements with no children (like , , or custom elements) were incorrectly being treated as empty. Changed logic: - Elements with NO children are now considered content (they're self-contained like images, icons, etc.) - Elements WITH children are only empty if all children are whitespace This preserves the whitespace filtering while correctly handling icon/image/custom elements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Refactor slot content detection into shared utility Extract the finicky slot content detection logic into a reusable utility function that can be used by other components. Changes: - Add packages/ui/src/v2/utils/slots.ts with nodeHasContent() and slotHasContent() functions - Replace inline hasContent logic in ct-card with slotHasContent() - Export slots utility from utils/index.ts This makes the logic: - Reusable across components that need slot content detection - Easier to test in isolation - Easier to maintain (single source of truth) - Better documented with JSDoc 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Fix slot content detection for elements with only non-text children The nodeHasContent() function now correctly handles elements that contain only non-text child elements (icons, buttons, images, etc.) by checking element.children.length before falling back to textContent checks. Previously, elements like `
` would be incorrectly marked as empty because textContent would be empty. This addresses cubic's feedback on PR #2152. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Reduce to minimal fix * Format pass --------- Co-authored-by: Claude Co-authored-by: Happy Co-authored-by: Ben Follington <5009316+bfollington@users.noreply.github.com> --- .../ui/src/v2/components/ct-card/ct-card.ts | 89 +++++++++++++++++-- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/v2/components/ct-card/ct-card.ts b/packages/ui/src/v2/components/ct-card/ct-card.ts index 40e35ba65c..5b2e85516f 100644 --- a/packages/ui/src/v2/components/ct-card/ct-card.ts +++ b/packages/ui/src/v2/components/ct-card/ct-card.ts @@ -19,6 +19,8 @@ import { BaseElement } from "../../core/base-element.ts"; *

Card content goes here

* Action * + * + * Uses JS to detect empty slots (CSS :has() can't distinguish assigned vs fallback content). */ export class CTCard extends BaseElement { @@ -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; } @@ -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; } @@ -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; } @@ -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; } @@ -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); @@ -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; From e9376853422b82a7d9d5b0ce947ecbb7c9b9623c Mon Sep 17 00:00:00 2001 From: Alex Komoroske Date: Mon, 1 Dec 2025 22:30:38 -0800 Subject: [PATCH 3/3] Add PDF support via ct-file-input base component (#2151) * feat(ui): Add ct-file-input base component Create generic file upload component that can handle any file type. Features: - Accepts any file type via 'accept' attribute - Smart preview rendering based on MIME type (images vs documents) - Protected methods for subclass extension (processFile, renderPreview, etc.) - No compression logic in base class - Cell-based reactive data binding This component serves as the base for ct-image-input and enables PDF/document upload use cases. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * refactor(ui): Refactor ct-image-input to extend ct-file-input Convert ct-image-input from standalone component to subclass of ct-file-input. Changes: - Extends CTFileInput instead of BaseElement - ImageData now extends FileData - Compression logic moved to override methods - Image dimension extraction in processFile override - Capture attribute added via renderFileInput override - Backward compatibility maintained via property aliases (images/maxImages) - Event details include both 'images' and 'files' properties This reduces code duplication and establishes clear inheritance hierarchy while maintaining full backward compatibility with existing code. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * test(ui): Add manual test file for ct-file-input and ct-image-input Create HTML test page to verify: - ct-file-input with PDF support - ct-file-input with mixed image/PDF support - ct-image-input backward compatibility (images property) - ct-image-input camera capture support Test file includes event listeners to verify both 'images' and 'files' properties are present for backward compatibility. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * feat(types): Add JSX types for ct-file-input component Add TypeScript JSX definitions for ct-file-input: - CTFileInputElement interface - CTFileInputAttributes interface with all properties - IntrinsicElements entry for JSX support This enables type-safe usage of ct-file-input in patterns. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * fix: Address cubic code review issues 1. **maxSizeBytes now used in ct-file-input**: Added console.warn when files exceed size threshold 2. **max-file guard fixed**: Only enforces limit in multiple mode, allowing single-file replacement 3. **Removed test-file-input.html**: Browser cannot load TypeScript directly; test-file-upload.tsx pattern serves this purpose 4. **Fixed ct-image-input handler conflict**: Made parent _handleFileChange protected, subclass calls via super 5. **Fixed event recursion**: Override emit() method to add backward-compat properties instead of listening/re-emitting These changes ensure proper file validation, fix single-file selection replacement, and eliminate infinite recursion in event handling. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * fix: Address additional cubic code review issues - Move maxSizeBytes check to after compression so it validates the final file size - Fix max file guard to only apply in multiple mode, allowing single-file replacement - Note: test-file-input.html import issue is moot as file was removed in earlier commit Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Remove test files, screenshots, and bug reports from PR These files were accidentally included and are not part of the ct-file-input/ct-image-input PDF support feature: - 76 playwright screenshot files - 8 bug report and documentation files - 11 test pattern files - package.json/package-lock.json (playwright test dependency) This reduces the PR from 6,656 lines to just the actual feature code. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Format and lint --------- Co-authored-by: Claude Co-authored-by: Happy Co-authored-by: Ben Follington <5009316+bfollington@users.noreply.github.com> --- packages/html/src/jsx.d.ts | 31 + .../components/ct-file-input/ct-file-input.ts | 553 ++++++++++++++++++ .../src/v2/components/ct-file-input/index.ts | 1 + .../ct-image-input/ct-image-input.ts | 485 ++++----------- 4 files changed, 692 insertions(+), 378 deletions(-) create mode 100644 packages/ui/src/v2/components/ct-file-input/ct-file-input.ts create mode 100644 packages/ui/src/v2/components/ct-file-input/index.ts diff --git a/packages/html/src/jsx.d.ts b/packages/html/src/jsx.d.ts index 6b220f8b32..6309dcef80 100644 --- a/packages/html/src/jsx.d.ts +++ b/packages/html/src/jsx.d.ts @@ -2864,6 +2864,7 @@ 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 {} @@ -3247,6 +3248,32 @@ interface CTInputLegacyAttributes extends CTHTMLAttributes { "customStyle"?: string; } +interface CTFileInputAttributes extends CTHTMLAttributes { + "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 + "onct-change"?: EventHandler; + "onct-remove"?: EventHandler; + "onct-error"?: EventHandler; +} + interface CTImageInputAttributes extends CTHTMLAttributes { "multiple"?: boolean; "maxImages"?: number; @@ -3811,6 +3838,10 @@ declare global { CTInputAttributes, CTInputElement >; + "ct-file-input": CTDOM.DetailedHTMLProps< + CTFileInputAttributes, + CTFileInputElement + >; "ct-image-input": CTDOM.DetailedHTMLProps< CTImageInputAttributes, CTImageInputElement diff --git a/packages/ui/src/v2/components/ct-file-input/ct-file-input.ts b/packages/ui/src/v2/components/ct-file-input/ct-file-input.ts new file mode 100644 index 0000000000..ac2bdad416 --- /dev/null +++ b/packages/ui/src/v2/components/ct-file-input/ct-file-input.ts @@ -0,0 +1,553 @@ +import { css, html, type TemplateResult } from "lit"; +import { property } from "lit/decorators.js"; +import { BaseElement } from "../../core/base-element.ts"; +import type { ButtonSize, ButtonVariant } from "../ct-button/ct-button.ts"; +import { type Cell } from "@commontools/runner"; +import { createArrayCellController } from "../../core/cell-controller.ts"; +import { consume } from "@lit/context"; +import { + applyThemeToElement, + type CTTheme, + defaultTheme, + themeContext, +} from "../theme-context.ts"; +import { formatFileSize } from "../../utils/image-compression.ts"; +import "../ct-button/ct-button.ts"; + +/** + * Generic file data structure + */ +export interface FileData { + id: string; + name: string; + url: string; // data URL + data: string; // data URL (kept for compatibility) + timestamp: number; + size: number; + type: string; // MIME type + + // Optional metadata (can be populated by subclasses) + width?: number; + height?: number; + metadata?: Record; +} + +/** + * CTFileInput - Generic file upload component + * + * @element ct-file-input + * + * @attr {boolean} multiple - Allow multiple files (default: false) + * @attr {number} maxFiles - Max number of files (default: unlimited) + * @attr {string} accept - File types to accept (default: "*\/*") + * @attr {string} buttonText - Custom button text (default: "📎 Add File") + * @attr {string} variant - Button style variant + * @attr {string} size - Button size + * @attr {boolean} showPreview - Show file previews (default: true) + * @attr {string} previewSize - Preview thumbnail size: "sm" | "md" | "lg" + * @attr {boolean} removable - Allow removing files (default: true) + * @attr {boolean} disabled - Disable the input + * @attr {number} maxSizeBytes - Max size warning threshold (default: none) + * + * @fires ct-change - Fired when file(s) are added. detail: { files: FileData[] } + * @fires ct-remove - Fired when a file is removed. detail: { id: string, files: FileData[] } + * @fires ct-error - Fired when an error occurs. detail: { error: Error, message: string } + * + * @example + * + * @example + * + */ +export class CTFileInput extends BaseElement { + static override styles = [ + BaseElement.baseStyles, + css` + :host { + display: block; + box-sizing: border-box; + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + + .container { + display: flex; + flex-direction: column; + gap: var(--ct-theme-spacing-normal, 0.75rem); + } + + input[type="file"] { + display: none; + } + + .previews { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--ct-theme-spacing-normal, 0.75rem); + } + + .preview-item { + position: relative; + border-radius: var( + --ct-theme-border-radius, + var(--ct-border-radius-md, 0.375rem) + ); + overflow: hidden; + border: 1px solid + var(--ct-theme-color-border, var(--ct-color-gray-200, #e5e7eb)); + background: var( + --ct-theme-color-background, + var(--ct-color-gray-50, #f9fafb) + ); + } + + .preview-item img { + width: 100%; + height: 120px; + object-fit: cover; + display: block; + } + + .preview-item.size-sm img, + .preview-item.size-sm .file-preview { + height: 80px; + } + + .preview-item.size-lg img, + .preview-item.size-lg .file-preview { + height: 160px; + } + + .file-preview { + width: 100%; + height: 120px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem; + background: var( + --ct-theme-color-background, + var(--ct-color-gray-50, #f9fafb) + ); + } + + .file-icon { + font-size: 2rem; + line-height: 1; + } + + .file-name { + font-size: 0.75rem; + text-align: center; + word-break: break-word; + color: var( + --ct-theme-color-text-muted, + var(--ct-color-gray-600, #4b5563) + ); + } + + .remove-button { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.6); + color: white; + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0; + transition: background 0.2s ease; + } + + .remove-button:hover { + background: rgba(0, 0, 0, 0.8); + } + + .file-info { + padding: 6px 8px; + font-size: 0.75rem; + color: var( + --ct-theme-color-text-muted, + var(--ct-color-gray-600, #4b5563) + ); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .loading { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + color: var( + --ct-theme-color-text-muted, + var(--ct-color-gray-600, #4b5563) + ); + font-size: 0.875rem; + } + `, + ]; + + @property({ type: Boolean }) + multiple = false; + + @property({ type: Number }) + maxFiles?: number; + + @property({ type: String }) + accept = "*/*"; + + @property({ type: String }) + buttonText = "📎 Add File"; + + @property({ type: String }) + variant: ButtonVariant = "outline"; + + @property({ type: String }) + size: ButtonSize = "default"; + + @property({ type: Boolean }) + showPreview = true; + + @property({ type: String }) + previewSize: "sm" | "md" | "lg" = "md"; + + @property({ type: Boolean }) + removable = true; + + @property({ type: Boolean }) + disabled = false; + + @property({ type: Number }) + maxSizeBytes?: number; + + @property({ type: Array }) + files: Cell | FileData[] = []; + + @property({ type: Boolean }) + protected loading = false; + + // Theme consumption + @consume({ context: themeContext, subscribe: true }) + @property({ attribute: false }) + declare theme?: CTTheme; + + protected _cellController = createArrayCellController(this, { + onChange: (_newFiles: FileData[], _oldFiles: FileData[]) => { + this.requestUpdate(); + }, + }); + + protected _generateId(): string { + return `file-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + protected getFiles(): FileData[] { + return [...this._cellController.getValue()]; + } + + protected setFiles(newFiles: FileData[]): void { + this._cellController.setValue(newFiles); + } + + /** + * Process a file and return FileData + * Subclasses can override this to add custom processing + */ + protected async processFile(file: File): Promise { + const id = this._generateId(); + const dataUrl = await this._readFileAsDataURL(file); + + return { + id, + name: file.name, + url: dataUrl, + data: dataUrl, + timestamp: Date.now(), + size: file.size, + type: file.type, + }; + } + + /** + * Determine if a file should be compressed + * Base class: never compress (subclasses override) + */ + protected shouldCompressFile(_file: File): boolean { + return false; + } + + /** + * Compress a file + * Subclasses override this for specific compression logic + */ + protected compressFile(file: File): Promise { + return Promise.resolve(file); + } + + /** + * Render preview for a file + * Subclasses can override for custom preview rendering + */ + protected renderPreview(file: FileData): TemplateResult { + // Smart default preview based on MIME type + if (file.type.startsWith("image/")) { + return html` + ${file.name} + `; + } + + // Generic file preview with icon + const icon = this._getFileIcon(file.type); + return html` +
+
${icon}
+
${file.name}
+
+ `; + } + + /** + * Render the file input element + * Subclasses can override to add custom attributes (e.g., capture) + */ + protected renderFileInput(): TemplateResult { + return html` + + `; + } + + protected renderButton(): TemplateResult { + return html` + + ${this.loading ? "Loading..." : this.buttonText} + + `; + } + + protected renderPreviews(): TemplateResult { + const currentFiles = this.getFiles(); + + if (!this.showPreview || currentFiles.length === 0) { + return html` + + `; + } + + return html` +
+ ${currentFiles.map( + (file) => + html` +
+ ${this.renderPreview(file)} ${this.removable + ? html` + + ` + : ""} +
+ ${file.name} (${formatFileSize(file.size)}) +
+
+ `, + )} +
+ `; + } + + private _getFileIcon(mimeType: string): string { + if (mimeType.startsWith("image/")) return "🖼️"; + if (mimeType === "application/pdf") return "📄"; + if (mimeType.startsWith("video/")) return "🎬"; + if (mimeType.startsWith("audio/")) return "🎵"; + if (mimeType.startsWith("text/")) return "📝"; + if ( + mimeType.includes("word") || + mimeType.includes("document") || + mimeType.includes("openxmlformats") + ) { + return "📝"; + } + if (mimeType.includes("sheet") || mimeType.includes("excel")) return "📊"; + if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) { + return "📽️"; + } + return "📎"; + } + + private _readFileAsDataURL(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); + } + + private _handleButtonClick() { + const input = this.shadowRoot?.querySelector( + 'input[type="file"]', + ) as HTMLInputElement; + input?.click(); + } + + protected async _handleFileChange(event: Event) { + const input = event.target as HTMLInputElement; + const files = input.files; + + if (!files || files.length === 0) return; + + const currentFiles = this.getFiles(); + + // Check max files limit (only for multiple mode) + // Single-file mode replaces existing files, so no max check needed + if (this.multiple && this.maxFiles) { + const totalFiles = currentFiles.length + files.length; + if (totalFiles > this.maxFiles) { + this.emit("ct-error", { + error: new Error("Max files exceeded"), + message: `Maximum ${this.maxFiles} files allowed`, + }); + return; + } + } + + this.loading = true; + + try { + const newFiles: FileData[] = []; + + for (const file of Array.from(files)) { + try { + // Check if should compress (subclass decides) + let fileToProcess: Blob = file; + if (this.shouldCompressFile(file)) { + fileToProcess = await this.compressFile(file); + } + + // Check file size AFTER compression if maxSizeBytes is set + if (this.maxSizeBytes && fileToProcess.size > this.maxSizeBytes) { + console.warn( + `File ${file.name} (${ + formatFileSize(fileToProcess.size) + }) exceeds maxSizeBytes (${ + formatFileSize(this.maxSizeBytes) + }) even after compression`, + ); + } + + // Process file (subclass can override) + const fileData = await this.processFile( + new File([fileToProcess], file.name, { type: file.type }), + ); + newFiles.push(fileData); + } catch (error) { + this.emit("ct-error", { + error: error as Error, + message: `Failed to process ${file.name}`, + }); + } + } + + // When multiple is false, replace existing files instead of appending + const updatedFiles = this.multiple + ? [...currentFiles, ...newFiles] + : newFiles; + this.setFiles(updatedFiles); + this.emit("ct-change", { files: updatedFiles }); + } finally { + this.loading = false; + // Reset input so same file can be selected again + input.value = ""; + } + } + + private _handleRemove(id: string) { + const currentFiles = this.getFiles(); + const updatedFiles = currentFiles.filter((file) => file.id !== id); + this.setFiles(updatedFiles); + this.emit("ct-remove", { id, files: updatedFiles }); + this.emit("ct-change", { files: updatedFiles }); + } + + override connectedCallback() { + super.connectedCallback(); + // CellController handles subscription automatically via ReactiveController + } + + override disconnectedCallback() { + super.disconnectedCallback(); + // CellController handles cleanup automatically via ReactiveController + } + + override willUpdate(changedProperties: Map) { + super.willUpdate(changedProperties); + + // If the files property itself changed (e.g., switched to a different cell) + if (changedProperties.has("files")) { + // Bind the new value (Cell or plain array) to the controller + this._cellController.bind(this.files); + } + } + + override updated(changedProperties: Map) { + super.updated(changedProperties); + + if (changedProperties.has("theme")) { + applyThemeToElement(this, this.theme ?? defaultTheme); + } + } + + override firstUpdated() { + // Bind the initial value to the cell controller + this._cellController.bind(this.files); + + // Apply theme after first render + applyThemeToElement(this, this.theme ?? defaultTheme); + } + + override render() { + return html` +
+ ${this.renderFileInput()} ${this.renderButton()} ${this.loading + ? html` +
Processing files...
+ ` + : ""} ${this.renderPreviews()} +
+ `; + } +} + +customElements.define("ct-file-input", CTFileInput); diff --git a/packages/ui/src/v2/components/ct-file-input/index.ts b/packages/ui/src/v2/components/ct-file-input/index.ts new file mode 100644 index 0000000000..f0562b73a7 --- /dev/null +++ b/packages/ui/src/v2/components/ct-file-input/index.ts @@ -0,0 +1 @@ +export { CTFileInput, type FileData } from "./ct-file-input.ts"; diff --git a/packages/ui/src/v2/components/ct-image-input/ct-image-input.ts b/packages/ui/src/v2/components/ct-image-input/ct-image-input.ts index c2138685b0..90cfba2b8c 100644 --- a/packages/ui/src/v2/components/ct-image-input/ct-image-input.ts +++ b/packages/ui/src/v2/components/ct-image-input/ct-image-input.ts @@ -1,25 +1,14 @@ -import { css, html } from "lit"; +import { html, type TemplateResult } from "lit"; import { property } from "lit/decorators.js"; -import { BaseElement } from "../../core/base-element.ts"; import { ifDefined } from "lit/directives/if-defined.js"; -import type { ButtonSize, ButtonVariant } from "../ct-button/ct-button.ts"; -import { type Cell } from "@commontools/runner"; -import { createArrayCellController } from "../../core/cell-controller.ts"; -import { consume } from "@lit/context"; -import { - applyThemeToElement, - type CTTheme, - defaultTheme, - themeContext, -} from "../theme-context.ts"; +import { CTFileInput, type FileData } from "../ct-file-input/ct-file-input.ts"; import { compressImage, formatFileSize, } from "../../utils/image-compression.ts"; -import "../ct-button/ct-button.ts"; /** - * Image data structure + * Image-specific metadata (EXIF data) */ export interface ExifData { // Core metadata @@ -52,27 +41,26 @@ export interface ExifData { raw?: Record; } -export interface ImageData { - id: string; - name: string; - url: string; - data: string; - timestamp: number; +/** + * Image data structure (extends FileData with image-specific fields) + */ +export interface ImageData extends FileData { width?: number; height?: number; - size: number; - type: string; exif?: ExifData; } /** * CTImageInput - Image capture and upload component with camera support * + * Extends CTFileInput with image-specific features like compression, EXIF extraction, + * and camera capture support. + * * @element ct-image-input * * @attr {boolean} multiple - Allow multiple images (default: false) * @attr {number} maxImages - Max number of images (default: unlimited) - * @attr {number} maxSizeBytes - Max size in bytes before compression (default: no compression) + * @attr {number} maxSizeBytes - Max size in bytes before compression (default: 5MB) * @attr {string} capture - Capture mode: "user" | "environment" | false * @attr {string} buttonText - Custom button text (default: "📷 Add Photo") * @attr {string} variant - Button style variant @@ -91,243 +79,52 @@ export interface ImageData { * @example * */ -export class CTImageInput extends BaseElement { - static override styles = [ - BaseElement.baseStyles, - css` - :host { - display: block; - box-sizing: border-box; - } - - *, - *::before, - *::after { - box-sizing: inherit; - } - - .container { - display: flex; - flex-direction: column; - gap: var(--ct-theme-spacing-normal, 0.75rem); - } - - input[type="file"] { - display: none; - } - - .previews { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: var(--ct-theme-spacing-normal, 0.75rem); - } - - .preview-item { - position: relative; - border-radius: var( - --ct-theme-border-radius, - var(--ct-border-radius-md, 0.375rem) - ); - overflow: hidden; - border: 1px solid - var(--ct-theme-color-border, var(--ct-color-gray-200, #e5e7eb)); - background: var( - --ct-theme-color-background, - var(--ct-color-gray-50, #f9fafb) - ); - } - - .preview-item img { - width: 100%; - height: 120px; - object-fit: cover; - display: block; - } - - .preview-item.size-sm img { - height: 80px; - } - - .preview-item.size-lg img { - height: 160px; - } - - .remove-button { - position: absolute; - top: 4px; - right: 4px; - background: rgba(0, 0, 0, 0.6); - color: white; - border: none; - border-radius: 50%; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 16px; - line-height: 1; - padding: 0; - transition: background 0.2s ease; - } - - .remove-button:hover { - background: rgba(0, 0, 0, 0.8); - } - - .image-info { - padding: 6px 8px; - font-size: 0.75rem; - color: var(--ct-theme-color-text-muted, var(--ct-color-gray-600, #4b5563)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .loading { - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - color: var(--ct-theme-color-text-muted, var(--ct-color-gray-600, #4b5563)); - font-size: 0.875rem; - } - `, - ]; - - @property({ type: Boolean }) - multiple = false; - - @property({ type: Number }) - maxImages?: number; - - @property({ type: String }) - capture?: "user" | "environment" | false; - +export class CTImageInput extends CTFileInput { + // Override default properties with image-specific defaults @property({ type: String }) - buttonText = "📷 Add Photo"; + override buttonText = "📷 Add Photo"; @property({ type: String }) - variant: ButtonVariant = "outline"; + override accept = "image/*"; - @property({ type: String }) - size: ButtonSize = "default"; - - @property({ type: Boolean }) - showPreview = true; + @property({ type: Number }) + override maxSizeBytes = 5 * 1024 * 1024; // Default to 5MB for images + // Image-specific properties @property({ type: String }) - previewSize: "sm" | "md" | "lg" = "md"; - - @property({ type: Boolean }) - removable = true; + capture?: "user" | "environment" | false; @property({ type: Boolean }) - disabled = false; + extractExif = false; - @property({ type: Number }) - maxSizeBytes?: number = 5 * 1024 * 1024; // Default to 5MB - - @property({ type: Array }) - images: Cell | ImageData[] = []; - - @property({ type: Boolean }) - private loading = false; - - // Theme consumption - @consume({ context: themeContext, subscribe: true }) - @property({ attribute: false }) - declare theme?: CTTheme; - - private _cellController = createArrayCellController(this, { - onChange: (_newImages: ImageData[], _oldImages: ImageData[]) => { - // Just request an update to re-render with the new cell value - // Don't emit ct-change here - that causes infinite loops when using handlers - this.requestUpdate(); - }, - }); - - private _generateId(): string { - return `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + // Provide backward-compatible property alias + get images(): ImageData[] | any { + return this.files; } - - private getImages(): ImageData[] { - return [...this._cellController.getValue()]; + set images(value: ImageData[] | any) { + this.files = value; } - private setImages(newImages: ImageData[]): void { - this._cellController.setValue(newImages); + // Alias maxImages to maxFiles for backward compatibility + get maxImages(): number | undefined { + return this.maxFiles; } - - private _handleButtonClick() { - const input = this.shadowRoot?.querySelector( - 'input[type="file"]', - ) as HTMLInputElement; - input?.click(); + set maxImages(value: number | undefined) { + this.maxFiles = value; } - private async _handleFileChange(event: Event) { - const input = event.target as HTMLInputElement; - const files = input.files; - - if (!files || files.length === 0) return; - - const currentImages = this.getImages(); - - // Check max images limit - if ( - this.maxImages && - currentImages.length + files.length > this.maxImages - ) { - this.emit("ct-error", { - error: new Error("Max images exceeded"), - message: `Maximum ${this.maxImages} images allowed`, - }); - return; - } - - this.loading = true; - - try { - const newImages: ImageData[] = []; - - for (const file of Array.from(files)) { - try { - const imageData = await this._processFile(file); - newImages.push(imageData); - } catch (error) { - this.emit("ct-error", { - error: error as Error, - message: `Failed to process ${file.name}`, - }); - } - } - - // When multiple is false, replace existing images instead of appending - const updatedImages = this.multiple - ? [...currentImages, ...newImages] - : newImages; - this.setImages(updatedImages); - this.emit("ct-change", { images: updatedImages }); - } finally { - this.loading = false; - // Reset input so same file can be selected again - input.value = ""; - } + // Override: Images should be compressed if maxSizeBytes is set and exceeded + protected override shouldCompressFile(file: File): boolean { + return !!(this.maxSizeBytes && file.size > this.maxSizeBytes); } - /** - * Compress an image file using the image compression utility - * @param file - The image file to compress - * @param maxSizeBytes - Target maximum size in bytes - * @returns Compressed blob - */ - private async _compressImage( - file: File, - maxSizeBytes: number, - ): Promise { - const result = await compressImage(file, { maxSizeBytes }); + // Override: Use image compression utility + protected override async compressFile(file: File): Promise { + if (!this.maxSizeBytes) return file; + + const result = await compressImage(file, { + maxSizeBytes: this.maxSizeBytes, + }); // Log compression result if (result.compressedSize < result.originalSize) { @@ -338,10 +135,10 @@ export class CTImageInput extends BaseElement { ); } - if (result.compressedSize > maxSizeBytes) { + if (result.compressedSize > this.maxSizeBytes) { console.warn( `Could not compress ${file.name} below ${ - formatFileSize(maxSizeBytes) + formatFileSize(this.maxSizeBytes) }. Final size: ${formatFileSize(result.compressedSize)}`, ); } @@ -349,164 +146,96 @@ export class CTImageInput extends BaseElement { return result.blob; } - private async _processFile(file: File): Promise { - const id = this._generateId(); - - // Compress if maxSizeBytes is set and file exceeds it - let fileToProcess: Blob = file; - if (this.maxSizeBytes && file.size > this.maxSizeBytes) { - try { - fileToProcess = await this._compressImage(file, this.maxSizeBytes); - } catch (error) { - console.error("Compression failed, using original file:", error); - // Continue with original file if compression fails - } - } + // Override: Extract image dimensions and EXIF + protected override async processFile(file: File): Promise { + // Get base file data + const baseData = await super.processFile(file); + // Load image to get dimensions return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - const dataUrl = reader.result as string; - - // Get image dimensions from the data URL - const img = new Image(); - img.onload = () => { - const imageData: ImageData = { - id, - name: file.name || `Photo-${Date.now()}.jpg`, - url: dataUrl, - data: dataUrl, - timestamp: Date.now(), - width: img.width, - height: img.height, - size: fileToProcess.size, // Use compressed size - type: fileToProcess.type || file.type, - }; - - resolve(imageData); - }; + const img = new Image(); - img.onerror = () => { - reject(new Error("Failed to load image")); + img.onload = () => { + const imageData: ImageData = { + ...baseData, + width: img.width, + height: img.height, }; - img.src = dataUrl; - }; + // TODO(#exif): Add EXIF extraction if this.extractExif is true - reader.onerror = () => { - reject(new Error("Failed to read file")); + resolve(imageData); }; - reader.readAsDataURL(fileToProcess); + img.onerror = () => reject(new Error("Failed to load image")); + img.src = baseData.url; }); } - private _handleRemove(id: string) { - const currentImages = this.getImages(); - const updatedImages = currentImages.filter((img) => img.id !== id); - this.setImages(updatedImages); - this.emit("ct-remove", { id, images: updatedImages }); - this.emit("ct-change", { images: updatedImages }); - } - - override connectedCallback() { - super.connectedCallback(); - // CellController handles subscription automatically via ReactiveController - } - - override disconnectedCallback() { - super.disconnectedCallback(); - // CellController handles cleanup automatically via ReactiveController - } - - override willUpdate(changedProperties: Map) { - super.willUpdate(changedProperties); - - // If the images property itself changed (e.g., switched to a different cell) - if (changedProperties.has("images")) { - // Bind the new value (Cell or plain array) to the controller - this._cellController.bind(this.images); - } - } - - override updated(changedProperties: Map) { - super.updated(changedProperties); - - if (changedProperties.has("theme")) { - applyThemeToElement(this, this.theme ?? defaultTheme); - } + // Override: Always use for images (we know they're images) + protected override renderPreview(file: ImageData): TemplateResult { + return html` + ${file.name} + `; } - override firstUpdated() { - // Bind the initial value to the cell controller - this._cellController.bind(this.images); + // Override: Add capture attribute to file input + protected override renderFileInput(): TemplateResult { + const captureAttr = this.capture !== false ? this.capture : undefined; - // Apply theme after first render - applyThemeToElement(this, this.theme ?? defaultTheme); + return html` + + `; } + // Override render to keep "Processing images..." text override render() { - // Only set capture attribute if explicitly specified (not false) - const captureAttr = this.capture !== false ? this.capture : undefined; - const currentImages = this.getImages(); - return html`
- - - - ${this.loading ? "Loading..." : this.buttonText} - - - ${this.loading + ${this.renderFileInput()} ${this.renderButton()} ${this.loading ? html`
Processing images...
` - : ""} ${this.showPreview && currentImages.length > 0 - ? html` -
- ${currentImages.map( - (image) => - html` -
- ${image.name} - ${this.removable - ? html` - - ` - : ""} -
- ${image.name} (${formatFileSize(image.size)}) -
-
- `, - )} -
- ` - : ""} + : ""} ${this.renderPreviews()}
`; } + + // Internal handler that calls parent's protected handler + private _handleFileChangeInternal = (event: Event) => { + // Call parent's protected _handleFileChange method + super._handleFileChange(event); + }; + + // Override emit to add backward-compatible event details + protected override emit( + eventName: string, + detail?: T, + options?: EventInit, + ): boolean { + if (eventName === "ct-change" && (detail as any)?.files) { + // Add 'images' property for backward compatibility + return super.emit(eventName, { + ...detail, + images: (detail as any).files, + } as T, options); + } else if (eventName === "ct-remove" && (detail as any)?.files) { + // Add 'images' property for backward compatibility + return super.emit(eventName, { + ...detail, + images: (detail as any).files, + } as T, options); + } else { + return super.emit(eventName, detail, options); + } + } } customElements.define("ct-image-input", CTImageInput);