webui: convert sketch-todo-panel to SketchTailwindElement with comprehensive test suite
Convert sketch-todo-panel component from LitElement with CSS-in-JS to SketchTailwindElement
inheritance using Tailwind utility classes, and add complete testing infrastructure with
TypeScript demo module and comprehensive test coverage.
Component Conversion:
- Replace LitElement with SketchTailwindElement inheritance to disable shadow DOM
- Remove 200+ lines of CSS-in-JS styles in favor of Tailwind utility classes
- Convert all styling to Tailwind class compositions while maintaining visual parity
- Add inline fadeIn animation using <style> tag following established patterns
- Preserve all existing functionality: todo rendering, comment system, loading states
CSS-to-Tailwind Mapping:
- Main container: flex flex-col h-full bg-transparent overflow-hidden
- Header section: py-2 px-3 border-b border-gray-300 bg-gray-100 font-semibold text-xs
- Content area: flex-1 overflow-y-auto p-2 pb-5 text-xs leading-relaxed min-h-0
- Todo items: flex items-start p-2 mb-1.5 rounded bg-white border border-gray-300 gap-2
- Loading state: animate-spin with proper Tailwind spinner classes
- Comment modal: fixed inset-0 bg-black bg-opacity-30 z-[10000] with centered content
- Status icons: ✅ completed, 🦉 in-progress, ⚪ queued with proper sizing
- Interactive buttons: hover states and transitions using Tailwind utility classes
Test Infrastructure:
- Create sketch-todo-panel.test.ts with 14 comprehensive test cases
- Test initialization, visibility, state management (loading/error/empty states)
- Test todo rendering: status icons, task descriptions, progress counts
- Test comment system: button visibility, modal interactions, event dispatch
- Test error handling: invalid JSON parsing, empty content scenarios
- Test Tailwind integration: proper class usage, shadow DOM disabled
- Test scrollable interface: large todo lists render and scroll correctly
- Use @sand4rt/experimental-ct-web framework following established patterns
- Include helper functions for mock TodoItem creation and test utilities
Demo Module Integration:
- Create sketch-todo-panel.demo.ts following established TypeScript demo pattern
- Add comprehensive demo scenarios: basic usage, loading/error/empty states
- Include large scrollable list demonstration with multiple todo items
- Add interactive comment functionality testing with event logging
- Add sketch-todo-panel to demo-runner.ts knownComponents registry
- Demonstrate all component states and user interactions comprehensively
TypeScript Compatibility:
- Fix property access for private @state() properties using component.evaluate()
- Use type assertions for addEventListener/removeEventListener on SketchTailwindElement
- Address interface compatibility issues with proper TypeScript patterns
- Remove unused imports and helper functions to maintain ESLint compliance
- Follow established patterns from other SketchTailwindElement components
Files Modified:
- sketch/webui/src/web-components/sketch-todo-panel.ts: TailwindElement conversion with complete CSS removal
- sketch/webui/src/web-components/demo/demo-framework/demo-runner.ts: Added component to registry
Files Added:
- sketch/webui/src/web-components/sketch-todo-panel.test.ts: Comprehensive test suite with 14 test cases
- sketch/webui/src/web-components/demo/sketch-todo-panel.demo.ts: Interactive TypeScript demo module
The conversion maintains complete functional parity while enabling consistent
Tailwind-based styling, comprehensive test coverage, and improved development
experience through integrated demo infrastructure.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: seada6841c7c375e5k
diff --git a/webui/src/web-components/sketch-todo-panel.ts b/webui/src/web-components/sketch-todo-panel.ts
index 7760faf..e76de46 100644
--- a/webui/src/web-components/sketch-todo-panel.ts
+++ b/webui/src/web-components/sketch-todo-panel.ts
@@ -1,10 +1,10 @@
-import { css, html, LitElement } from "lit";
+import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
-// import { unsafeHTML } from "lit/directives/unsafe-html.js"; // Unused import
import { TodoList, TodoItem } from "../types.js";
+import { SketchTailwindElement } from "./sketch-tailwind-element.js";
@customElement("sketch-todo-panel")
-export class SketchTodoPanel extends LitElement {
+export class SketchTodoPanel extends SketchTailwindElement {
@property()
visible: boolean = false;
@@ -26,322 +26,6 @@
@state()
private commentText: string = "";
- static styles = css`
- :host {
- display: flex;
- flex-direction: column;
- height: 100%;
- background-color: transparent; /* Let parent handle background */
- overflow: hidden; /* Ensure proper clipping */
- }
-
- .todo-header {
- padding: 8px 12px;
- border-bottom: 1px solid #e0e0e0;
- background-color: #f5f5f5;
- font-weight: 600;
- font-size: 13px;
- color: #333;
- display: flex;
- align-items: center;
- gap: 6px;
- }
-
- .todo-icon {
- width: 14px;
- height: 14px;
- color: #666;
- }
-
- .todo-content {
- flex: 1;
- overflow-y: auto;
- padding: 8px;
- padding-bottom: 20px; /* Extra bottom padding for better scrolling */
- font-family:
- system-ui,
- -apple-system,
- BlinkMacSystemFont,
- "Segoe UI",
- sans-serif;
- font-size: 12px;
- line-height: 1.4;
- /* Ensure scrollbar is always accessible */
- min-height: 0;
- }
-
- .todo-content.loading {
- display: flex;
- align-items: center;
- justify-content: center;
- color: #666;
- }
-
- .todo-content.error {
- color: #d32f2f;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .todo-content.empty {
- color: #999;
- font-style: italic;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- /* Todo item styling */
- .todo-item {
- display: flex;
- align-items: flex-start;
- padding: 8px;
- margin-bottom: 6px;
- border-radius: 4px;
- background-color: #fff;
- border: 1px solid #e0e0e0;
- gap: 8px;
- min-height: 24px; /* Ensure consistent height */
- }
-
- .todo-item.queued {
- border-left: 3px solid #e0e0e0;
- }
-
- .todo-item.in-progress {
- border-left: 3px solid #e0e0e0;
- }
-
- .todo-item.completed {
- border-left: 3px solid #e0e0e0;
- }
-
- .todo-status-icon {
- font-size: 14px;
- margin-top: 1px;
- flex-shrink: 0;
- }
-
- .todo-main {
- flex: 1;
- min-width: 0;
- }
-
- .todo-content-text {
- font-size: 12px;
- line-height: 1.3;
- color: #333;
- word-wrap: break-word;
- }
-
- .todo-item-content {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- width: 100%;
- min-height: 20px; /* Ensure consistent height */
- }
-
- .todo-text-section {
- flex: 1;
- min-width: 0;
- padding-right: 8px; /* Space between text and button column */
- }
-
- .todo-actions-column {
- flex-shrink: 0;
- display: flex;
- align-items: flex-start;
- width: 24px; /* Fixed width for button column */
- justify-content: center;
- }
-
- .comment-button {
- background: none;
- border: none;
- cursor: pointer;
- font-size: 14px;
- padding: 2px;
- color: #666;
- opacity: 0.7;
- transition: opacity 0.2s ease;
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .comment-button:hover {
- opacity: 1;
- background-color: rgba(0, 0, 0, 0.05);
- border-radius: 3px;
- }
-
- /* Comment box overlay */
- .comment-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.3);
- z-index: 10000;
- display: flex;
- align-items: center;
- justify-content: center;
- animation: fadeIn 0.2s ease-in-out;
- }
-
- .comment-box {
- background-color: white;
- border: 1px solid #ddd;
- border-radius: 6px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
- padding: 16px;
- width: 400px;
- max-width: 90vw;
- max-height: 80vh;
- overflow-y: auto;
- }
-
- .comment-box-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
- }
-
- .comment-box-header h3 {
- margin: 0;
- font-size: 14px;
- font-weight: 500;
- }
-
- .close-button {
- background: none;
- border: none;
- cursor: pointer;
- font-size: 18px;
- color: #666;
- padding: 2px 6px;
- }
-
- .close-button:hover {
- color: #333;
- }
-
- .todo-context {
- background-color: #f8f9fa;
- border: 1px solid #e9ecef;
- border-radius: 4px;
- padding: 8px;
- margin-bottom: 12px;
- font-size: 12px;
- }
-
- .todo-context-status {
- font-weight: 500;
- color: #666;
- margin-bottom: 4px;
- }
-
- .todo-context-task {
- color: #333;
- }
-
- .comment-textarea {
- width: 100%;
- min-height: 80px;
- padding: 8px;
- border: 1px solid #ddd;
- border-radius: 4px;
- resize: vertical;
- font-family: inherit;
- font-size: 12px;
- margin-bottom: 12px;
- box-sizing: border-box;
- }
-
- .comment-actions {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- }
-
- .comment-actions button {
- padding: 6px 12px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- }
-
- .cancel-button {
- background-color: transparent;
- border: 1px solid #ddd;
- color: #666;
- }
-
- .cancel-button:hover {
- background-color: #f5f5f5;
- }
-
- .submit-button {
- background-color: #4285f4;
- color: white;
- border: none;
- }
-
- .submit-button:hover {
- background-color: #3367d6;
- }
-
- @keyframes fadeIn {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
- }
-
- .todo-header-text {
- display: flex;
- align-items: center;
- gap: 6px;
- }
-
- .todo-count {
- background-color: #e0e0e0;
- color: #666;
- padding: 2px 6px;
- border-radius: 10px;
- font-size: 10px;
- font-weight: normal;
- }
-
- /* Loading spinner */
- .spinner {
- width: 20px;
- height: 20px;
- border: 2px solid #f3f3f3;
- border-top: 2px solid #3498db;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin-right: 8px;
- }
-
- @keyframes spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
- }
- `;
-
updateTodoContent(content: string) {
try {
if (!content.trim()) {
@@ -371,17 +55,21 @@
const showCommentButton = item.status !== "completed";
return html`
- <div class="todo-item ${item.status}">
- <div class="todo-status-icon">${statusIcon}</div>
- <div class="todo-item-content">
- <div class="todo-text-section">
- <div class="todo-content-text">${item.task}</div>
+ <div
+ class="flex items-start p-2 mb-1.5 rounded bg-white border border-gray-300 gap-2 min-h-6 border-l-[3px] border-l-gray-300"
+ >
+ <div class="text-sm mt-0.5 flex-shrink-0">${statusIcon}</div>
+ <div class="flex items-start justify-between w-full min-h-5">
+ <div class="flex-1 min-w-0 pr-2">
+ <div class="text-xs leading-snug text-gray-800 break-words">
+ ${item.task}
+ </div>
</div>
- <div class="todo-actions-column">
+ <div class="flex-shrink-0 flex items-start w-6 justify-center">
${showCommentButton
? html`
<button
- class="comment-button"
+ class="bg-transparent border-none cursor-pointer text-sm p-0.5 text-gray-500 opacity-70 transition-opacity duration-200 w-5 h-5 flex items-center justify-center hover:opacity-100 hover:bg-black/5 hover:bg-opacity-5 hover:rounded-sm"
@click="${() => this.openCommentBox(item)}"
title="Add comment about this TODO item"
>
@@ -402,7 +90,7 @@
const todoIcon = html`
<svg
- class="todo-icon"
+ class="w-3.5 h-3.5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -419,14 +107,22 @@
let contentElement;
if (this.loading) {
contentElement = html`
- <div class="todo-content loading">
- <div class="spinner"></div>
+ <div
+ class="flex-1 overflow-y-auto p-2 pb-5 text-xs leading-relaxed min-h-0 flex items-center justify-center text-gray-500"
+ >
+ <div
+ class="w-5 h-5 border-2 border-gray-200 border-t-blue-500 rounded-full animate-spin mr-2"
+ ></div>
Loading todos...
</div>
`;
} else if (this.error) {
contentElement = html`
- <div class="todo-content error">Error: ${this.error}</div>
+ <div
+ class="flex-1 overflow-y-auto p-2 pb-5 text-xs leading-relaxed min-h-0 text-red-600 flex items-center justify-center"
+ >
+ Error: ${this.error}
+ </div>
`;
} else if (
!this.todoList ||
@@ -434,7 +130,11 @@
this.todoList.items.length === 0
) {
contentElement = html`
- <div class="todo-content empty">No todos available</div>
+ <div
+ class="flex-1 overflow-y-auto p-2 pb-5 text-xs leading-relaxed min-h-0 text-gray-400 italic flex items-center justify-center"
+ >
+ No todos available
+ </div>
`;
} else {
const totalCount = this.todoList.items.length;
@@ -446,21 +146,31 @@
).length;
contentElement = html`
- <div class="todo-header">
- <div class="todo-header-text">
+ <div
+ class="py-2 px-3 border-b border-gray-300 bg-gray-100 font-semibold text-xs text-gray-800 flex items-center gap-1.5"
+ >
+ <div class="flex items-center gap-1.5">
${todoIcon}
<span>Sketching...</span>
- <span class="todo-count">${completedCount}/${totalCount}</span>
+ <span
+ class="bg-gray-300 text-gray-500 px-1.5 py-0.5 rounded-full text-xs font-normal"
+ >${completedCount}/${totalCount}</span
+ >
</div>
</div>
- <div class="todo-content">
+ <div
+ class="flex-1 overflow-y-auto p-2 pb-5 text-xs leading-relaxed min-h-0"
+ >
${this.todoList.items.map((item) => this.renderTodoItem(item))}
</div>
`;
}
return html`
- ${contentElement} ${this.showCommentBox ? this.renderCommentBox() : ""}
+ <div class="flex flex-col h-full bg-transparent overflow-hidden">
+ ${contentElement}
+ </div>
+ ${this.showCommentBox ? this.renderCommentBox() : ""}
`;
}
@@ -475,32 +185,64 @@
}[this.commentingItem.status] || this.commentingItem.status;
return html`
- <div class="comment-overlay" @click="${this.handleOverlayClick}">
- <div class="comment-box" @click="${this.stopPropagation}">
- <div class="comment-box-header">
- <h3>Comment on TODO Item</h3>
- <button class="close-button" @click="${this.closeCommentBox}">
+ <style>
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+ .animate-fade-in {
+ animation: fadeIn 0.2s ease-in-out;
+ }
+ </style>
+ <div
+ class="fixed inset-0 bg-black/30 z-[10000] flex items-center justify-center animate-fade-in"
+ @click="${this.handleOverlayClick}"
+ >
+ <div
+ class="bg-white border border-gray-300 rounded-md shadow-lg p-4 w-96 max-w-[90vw] max-h-[80vh] overflow-y-auto"
+ @click="${this.stopPropagation}"
+ >
+ <div class="flex justify-between items-center mb-3">
+ <h3 class="m-0 text-sm font-medium">Comment on TODO Item</h3>
+ <button
+ class="bg-transparent border-none cursor-pointer text-lg text-gray-500 px-1.5 py-0.5 hover:text-gray-800"
+ @click="${this.closeCommentBox}"
+ >
×
</button>
</div>
- <div class="todo-context">
- <div class="todo-context-status">Status: ${statusText}</div>
- <div class="todo-context-task">${this.commentingItem.task}</div>
+ <div
+ class="bg-gray-50 border border-gray-200 rounded p-2 mb-3 text-xs"
+ >
+ <div class="font-medium text-gray-500 mb-1">
+ Status: ${statusText}
+ </div>
+ <div class="text-gray-800">${this.commentingItem.task}</div>
</div>
<textarea
- class="comment-textarea"
+ class="w-full min-h-20 p-2 border border-gray-300 rounded resize-y text-xs mb-3 box-border"
placeholder="Type your comment about this TODO item..."
.value="${this.commentText}"
@input="${this.handleCommentInput}"
></textarea>
- <div class="comment-actions">
- <button class="cancel-button" @click="${this.closeCommentBox}">
+ <div class="flex justify-end gap-2">
+ <button
+ class="px-3 py-1.5 rounded cursor-pointer text-xs bg-transparent border border-gray-300 text-gray-500 hover:bg-gray-100"
+ @click="${this.closeCommentBox}"
+ >
Cancel
</button>
- <button class="submit-button" @click="${this.submitComment}">
+ <button
+ class="px-3 py-1.5 rounded cursor-pointer text-xs bg-blue-500 text-white border-none hover:bg-blue-600"
+ @click="${this.submitComment}"
+ >
Add Comment
</button>
</div>