├── images ├── visual_editor_search.png ├── todo-swipe-card-example.gif ├── todo-swipe-card_example_1.png ├── todo-swipe-card_example_2.png ├── visual_editor_card_editor.png ├── todo-swipe-card_example_advanced.png └── todo-swipe-card_example_4_advanced.png ├── .prettierrc ├── hacs.json ├── src ├── utils │ ├── Debug.js │ └── Constants.js ├── index.js ├── core │ ├── Dependencies.js │ ├── SubscriptionManager.js │ ├── CardBuilder.js │ ├── DialogManager.js │ └── TodoSwipeCard.js ├── features │ ├── Pagination.js │ ├── SearchFunctionality.js │ ├── SwipeGestures.js │ ├── DragDrop.js │ └── TodoOperations.js └── ui │ └── DomHelpers.js ├── .github ├── workflows │ └── hacs.yaml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ └── bug_report.yaml └── FUNDING.yml ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── rollup.config.js ├── package.json ├── scripts └── release.js └── README.md /images/visual_editor_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nutteloost/todo-swipe-card/HEAD/images/visual_editor_search.png -------------------------------------------------------------------------------- /images/todo-swipe-card-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nutteloost/todo-swipe-card/HEAD/images/todo-swipe-card-example.gif -------------------------------------------------------------------------------- /images/todo-swipe-card_example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nutteloost/todo-swipe-card/HEAD/images/todo-swipe-card_example_1.png -------------------------------------------------------------------------------- /images/todo-swipe-card_example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nutteloost/todo-swipe-card/HEAD/images/todo-swipe-card_example_2.png -------------------------------------------------------------------------------- /images/visual_editor_card_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nutteloost/todo-swipe-card/HEAD/images/visual_editor_card_editor.png -------------------------------------------------------------------------------- /images/todo-swipe-card_example_advanced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nutteloost/todo-swipe-card/HEAD/images/todo-swipe-card_example_advanced.png -------------------------------------------------------------------------------- /images/todo-swipe-card_example_4_advanced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nutteloost/todo-swipe-card/HEAD/images/todo-swipe-card_example_4_advanced.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Todo Swipe Card", 3 | "render_readme": true, 4 | "filename": "todo-swipe-card.js", 5 | "content_in_root": false, 6 | "homeassistant": "2023.11.0" 7 | } -------------------------------------------------------------------------------- /src/utils/Debug.js: -------------------------------------------------------------------------------- 1 | import { DEBUG } from './Constants.js'; 2 | 3 | /** 4 | * Log debug messages when debug mode is enabled 5 | * @param {string} message - Debug message 6 | * @param {any} data - Optional data to log 7 | */ 8 | export const debugLog = (message, data) => { 9 | if (!DEBUG) return; 10 | console.log(`[TodoSwipeCard] ${message}`, data !== undefined ? data : ''); 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | if: github.repository_owner == 'nutteloost' 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - name: HACS Action 16 | uses: "hacs/action@main" 17 | with: 18 | category: "plugin" 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Community Discussion 4 | url: https://community.home-assistant.io/t/todo-swipe-card-stylish-swipeable-todo-lists/888627 5 | about: Ask questions and discuss the card with other users in the Home Assistant Community Forum 6 | - name: 📖 Documentation 7 | url: https://github.com/nutteloost/todo-swipe-card#readme 8 | about: Read the full documentation and examples -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "prettier" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": "latest", 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-unused-vars": "warn", 16 | "no-console": "off", 17 | "prefer-const": "error" 18 | }, 19 | "globals": { 20 | "customElements": "readonly", 21 | "window": "readonly", 22 | "document": "readonly" 23 | } 24 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # IDE files 5 | .vscode/ 6 | .idea/ 7 | 8 | # OS files 9 | .DS_Store 10 | Thumbs.db 11 | @eaDir/ 12 | 13 | # Logs 14 | *.log 15 | npm-debug.log* 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Coverage directory 24 | coverage/ 25 | 26 | # Environment variables 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # Personal development files 34 | tree.txt 35 | todo.md 36 | RELEASE-GUIDE.md 37 | RELEASE-NOTES.md 38 | 39 | *.map -------------------------------------------------------------------------------- /src/utils/Constants.js: -------------------------------------------------------------------------------- 1 | // Version number 2 | export const VERSION = '3.5.0'; 3 | 4 | // Configurable debug mode - set to false for production 5 | export const DEBUG = true; 6 | 7 | // Todo sorting modes for display order configuration 8 | export const TodoSortMode = { 9 | NONE: 'none', 10 | ALPHA_ASC: 'alpha_asc', 11 | ALPHA_DESC: 'alpha_desc', 12 | DUEDATE_ASC: 'duedate_asc', 13 | DUEDATE_DESC: 'duedate_desc' 14 | }; 15 | 16 | export const TodoListEntityFeature = { 17 | CREATE_TODO_ITEM: 1, 18 | DELETE_TODO_ITEM: 2, 19 | UPDATE_TODO_ITEM: 4, 20 | MOVE_TODO_ITEM: 8, 21 | SET_DUE_DATE_ON_ITEM: 16, 22 | SET_DUE_DATETIME_ON_ITEM: 32, 23 | SET_DESCRIPTION_ON_ITEM: 64 24 | }; 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: nutteloost # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: nutteloost # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 nutteloost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { VERSION } from './utils/Constants.js'; 2 | import { debugLog } from './utils/Debug.js'; 3 | import { TodoSwipeCard } from './core/TodoSwipeCard.js'; 4 | import { TodoSwipeCardEditor } from './core/TodoSwipeCardEditor.js'; 5 | 6 | // Define custom elements 7 | customElements.define('todo-swipe-card', TodoSwipeCard); 8 | customElements.define('todo-swipe-card-editor', TodoSwipeCardEditor); 9 | 10 | // Add card to UI picker 11 | if (!window.customCards) { 12 | window.customCards = []; 13 | } 14 | 15 | // Ensure registration happens only once 16 | let registered = window.customCards.some((card) => card.type === 'todo-swipe-card'); 17 | if (!registered) { 18 | window.customCards.push({ 19 | type: 'todo-swipe-card', 20 | name: 'Todo Swipe Card', 21 | preview: true, // Enable preview 22 | description: 'A specialized swipe card for to-do lists' 23 | }); 24 | debugLog('Todo Swipe Card registered in customCards'); 25 | } 26 | 27 | // Version logging 28 | console.info( 29 | `%c TODO-SWIPE-CARD %c v${VERSION} %c - A swipeable card for to-do lists`, 30 | 'color: white; background: #4caf50; font-weight: 700;', 31 | 'color: #4caf50; background: white; font-weight: 700;', 32 | 'color: grey; background: white; font-weight: 400;' 33 | ); 34 | 35 | // Export main classes for potential external use 36 | export { TodoSwipeCard, TodoSwipeCardEditor }; 37 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | // Determine build mode 5 | const isProduction = process.env.NODE_ENV === 'production'; 6 | const shouldMinify = process.env.MINIFY === 'true'; 7 | 8 | console.log(`Building in ${isProduction ? 'production' : 'development'} mode${shouldMinify ? ' with minification' : ''}`); 9 | 10 | export default { 11 | input: 'src/index.js', 12 | output: { 13 | file: 'build/todo-swipe-card.js', 14 | format: 'es', 15 | inlineDynamicImports: true, // ADD THIS LINE - bundles everything into a single file 16 | sourcemap: process.env.SOURCEMAP === 'true' // Only create maps when explicitly requested 17 | }, 18 | plugins: [ 19 | nodeResolve({ 20 | browser: true, // Optimize for browser environment 21 | preferBuiltins: false 22 | }), 23 | 24 | // Apply terser only when explicitly requested via MINIFY=true 25 | shouldMinify && terser({ 26 | // Preserve essential function/class names for debugging 27 | mangle: { 28 | reserved: ['TodoSwipeCard', 'TodoSwipeCardEditor'], 29 | properties: { 30 | regex: /^_/ // Mangle private properties starting with underscore 31 | } 32 | }, 33 | 34 | // Compression settings 35 | compress: { 36 | // Remove console.log statements in minified builds (keeps console.error/warn) 37 | drop_console: ['log', 'debug'], 38 | drop_debugger: true, 39 | 40 | // Safe optimizations 41 | dead_code: true, 42 | unused: true, 43 | evaluate: true, 44 | reduce_vars: true, 45 | collapse_vars: true, 46 | 47 | // Preserve Home Assistant compatibility 48 | keep_fnames: /^(setConfig|connectedCallback|disconnectedCallback)$/, 49 | keep_classnames: true 50 | }, 51 | 52 | // Output formatting 53 | format: { 54 | comments: /^!/, // Keep comments starting with ! 55 | preserve_annotations: true // Keep @license comments 56 | }, 57 | 58 | // Source map options 59 | sourceMap: !isProduction 60 | }) 61 | ].filter(Boolean) // Remove falsy plugins 62 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-swipe-card", 3 | "version": "3.5.0", 4 | "description": "A specialized swipe card for todo lists in Home Assistant", 5 | "main": "build/todo-swipe-card.js", 6 | "type": "module", 7 | "scripts": { 8 | "// === CORE COMMANDS (no maps) ===": "", 9 | "dev": "NODE_ENV=development npx rollup -c -w", 10 | "deploy": "NODE_ENV=development npx rollup -c && npm run deploy:copy", 11 | "deploy:prod": "NODE_ENV=production MINIFY=false npx rollup -c && npm run deploy:copy", 12 | "deploy:minified": "NODE_ENV=production MINIFY=true npx rollup -c && npm run deploy:copy", 13 | "// === BUILD COMMANDS (with maps) ===": "", 14 | "build": "NODE_ENV=development SOURCEMAP=true npx rollup -c", 15 | "build:prod": "NODE_ENV=production MINIFY=false SOURCEMAP=true npx rollup -c", 16 | "build:minified": "NODE_ENV=production MINIFY=true SOURCEMAP=true npx rollup -c", 17 | "// === DEPLOY HELPER ===": "", 18 | "deploy:copy": "rsync -rlvh --delete ./build/ /workspace/homeassistant/www/todo-swipe-card/", 19 | "// === EXISTING COMMANDS ===": "", 20 | "lint": "npx eslint src/**/*.js", 21 | "lint:fix": "npx eslint src/**/*.js --fix", 22 | "format": "prettier --write 'src/**/*.js'", 23 | "format:check": "prettier --check 'src/**/*.js'", 24 | "version-check": "node -e \"const pkg=require('./package.json'); const fs=require('fs'); const constantsPath='src/utils/Constants.js'; const constants=fs.readFileSync(constantsPath,'utf8'); const versionMatch=constants.match(/export const VERSION = '([^']+)'/); if(!versionMatch || pkg.version !== versionMatch[1]) { console.error('❌ Version mismatch! package.json:',pkg.version,'Constants.js:',versionMatch?versionMatch[1]:'not found'); process.exit(1); } else { console.log('✅ Versions match:',pkg.version); }\"", 25 | "size-check": "npm run deploy:minified --silent && echo '📦 Final build size:' && ls -lh build/todo-swipe-card.js", 26 | "validate": "npm run version-check && npm run size-check", 27 | "pre-release": "npm run format && npm run lint && npm run deploy:prod && npm run validate", 28 | "release": "npm run clean && npm run pre-release && npm run deploy:minified && echo '🎉 Release ready! File: build/todo-swipe-card.js'", 29 | "release:auto": "node scripts/release.js", 30 | "clean": "rimraf build/*.js *.map", 31 | "test:build": "npm run deploy:prod && echo '✅ Production build test passed'" 32 | }, 33 | "dependencies": { 34 | "lit": "^3.0.0" 35 | }, 36 | "devDependencies": { 37 | "@rollup/plugin-node-resolve": "^15.0.0", 38 | "@rollup/plugin-terser": "^0.4.4", 39 | "eslint": "^8.0.0", 40 | "npm-run-all": "^4.1.5", 41 | "onchange": "^7.1.0", 42 | "rollup": "^3.0.0", 43 | "rimraf": "^5.0.0", 44 | "@rollup/plugin-replace": "^5.0.0", 45 | "rollup-plugin-filesize": "^10.0.0", 46 | "eslint-config-prettier": "^9.0.0", 47 | "prettier": "^3.0.0" 48 | }, 49 | "keywords": [ 50 | "home-assistant", 51 | "custom-card", 52 | "todo-list", 53 | "swipe-card" 54 | ], 55 | "author": "nutteloost", 56 | "license": "MIT" 57 | } 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature Request 2 | description: Suggest a new feature or improvement for the Todo Swipe Card 3 | title: "[FEATURE]: A brief, descriptive title for your feature request" 4 | labels: ["under review"] 5 | assignees: 6 | - nutteloost 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to suggest a new feature! Your detailed input helps us improve. 12 | Please provide as much information as possible by filling out the sections below. 13 | 14 | - type: textarea 15 | id: problem-description 16 | attributes: 17 | label: Is your feature request related to a problem? Please describe. 18 | description: A clear and concise description of what the problem is. Ex. "I'm always frustrated when..." or "It's currently difficult to..." 19 | placeholder: Describe the problem or limitation this feature would solve. 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | id: proposed-solution 25 | attributes: 26 | label: Describe the solution you'd like 27 | description: A clear and concise description of what you want to happen. How would this feature work? 28 | placeholder: e.g., "I would like to see a new option to..." or "The card should allow users to configure X by doing Y..." 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: use-case 34 | attributes: 35 | label: What is your use-case? Why is this feature important to you? 36 | description: | 37 | Explain how you would use this feature and why it's valuable. 38 | Provide specific examples of scenarios where this feature would be beneficial. 39 | This helps us understand the impact and prioritize requests. 40 | placeholder: "As a user, I want to [action] so that [benefit/goal]. For example, when I am designing a complex dashboard, I need to [specific task] which this feature would enable by..." 41 | validations: 42 | required: true 43 | 44 | - type: textarea 45 | id: alternatives-considered 46 | attributes: 47 | label: Have you considered any alternative solutions or workarounds? 48 | description: A clear and concise description of any alternative solutions or features you've considered or current workarounds you are using. 49 | placeholder: e.g., "I've tried X, but it doesn't fully meet my needs because..." or "Currently, I have to do Y, which is cumbersome." 50 | validations: 51 | required: false 52 | 53 | - type: textarea 54 | id: proposed-yaml 55 | attributes: 56 | label: Proposed YAML Configuration (Optional) 57 | description: If your feature involves configuration, please provide an example of how you imagine the YAML configuration might look. 58 | placeholder: | 59 | type: custom:todo-swipe-card 60 | entities: 61 | - todo.shopping_list 62 | new_feature_setting: true 63 | # Example of how the config might work 64 | render: yaml 65 | validations: 66 | required: false 67 | 68 | - type: input 69 | id: card-version 70 | attributes: 71 | label: Todo Swipe Card Version 72 | description: What version of the Todo Swipe Card are you using? (e.g., v3.0.0, latest) 73 | placeholder: "e.g., v3.0.0 or latest" 74 | validations: 75 | required: true 76 | 77 | - type: textarea 78 | id: additional-context 79 | attributes: 80 | label: Additional Context 81 | description: Add any other context, screenshots or links about the feature request here. 82 | placeholder: Drag and drop images or paste links. 83 | validations: 84 | required: false 85 | 86 | - type: checkboxes 87 | id: checklist 88 | attributes: 89 | label: Pre-Submission Checklist 90 | description: Please verify the following before submitting. 91 | options: 92 | - label: I have searched the existing [issues](https://github.com/nutteloost/todo-swipe-card/issues) to ensure this feature has not already been requested. 93 | required: true 94 | - label: I have provided a clear and detailed explanation of the feature. 95 | required: true 96 | - label: I have described my use-case and the benefits of this feature. 97 | required: true -------------------------------------------------------------------------------- /src/core/Dependencies.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SIMPLE DEPENDENCY LOADING - STATIC IMPORTS ONLY 3 | * 4 | * Uses only standard static ES6 imports that Rollup bundles into the final file. 5 | * No dynamic imports, no runtime detection - just simple, reliable imports. 6 | */ 7 | 8 | import { debugLog } from '../utils/Debug.js'; 9 | 10 | // Static imports - Rollup will bundle these into the final file 11 | import { LitElement, html, css } from 'lit'; 12 | 13 | // Simple fireEvent implementation (no external dependencies) 14 | export const fireEvent = (node, type, detail = {}) => { 15 | const event = new CustomEvent(type, { 16 | detail, 17 | bubbles: true, 18 | composed: true 19 | }); 20 | node.dispatchEvent(event); 21 | }; 22 | 23 | /** 24 | * Ensures all dependencies are properly loaded 25 | * @returns {Promise} True when dependencies are ready 26 | */ 27 | export async function ensureDependencies() { 28 | debugLog('SYSTEM', 'Using bundled LitElement dependencies'); 29 | return true; 30 | } 31 | 32 | /** 33 | * Gets the card helpers 34 | * @returns {Promise} Card helpers object 35 | */ 36 | export function getHelpers() { 37 | // Try HA's built-in card helpers first 38 | if (window.loadCardHelpers && typeof window.loadCardHelpers === 'function') { 39 | return window.loadCardHelpers(); 40 | } 41 | 42 | // Simple fallback that works offline 43 | return Promise.resolve({ 44 | createCardElement: async (config) => { 45 | try { 46 | // Try to create custom cards 47 | if (config.type && window.customElements && window.customElements.get(config.type)) { 48 | const element = document.createElement(config.type); 49 | if (element.setConfig) { 50 | element.setConfig(config); 51 | } 52 | return element; 53 | } 54 | 55 | // Try built-in cards with hui- prefix 56 | if (config.type && !config.type.startsWith('custom:')) { 57 | const huiType = `hui-${config.type}-card`; 58 | if (window.customElements && window.customElements.get(huiType)) { 59 | const element = document.createElement(config.type); 60 | if (element.setConfig) { 61 | element.setConfig(config); 62 | } 63 | return element; 64 | } 65 | } 66 | 67 | // Simple placeholder for unknown cards 68 | const element = document.createElement('div'); 69 | element.innerHTML = ` 70 | 71 |
72 | 73 |
${config.type}
74 |
Card type not available
75 |
76 |
77 | `; 78 | return element.firstElementChild; 79 | } catch (error) { 80 | // Error card 81 | const element = document.createElement('div'); 82 | element.innerHTML = ` 83 | 84 |
85 | 86 |
Card Error
87 |
${config.type}
88 |
${error.message}
89 |
90 |
91 | `; 92 | return element.firstElementChild; 93 | } 94 | }, 95 | 96 | createErrorCardElement: (config, error) => { 97 | const element = document.createElement('div'); 98 | element.innerHTML = ` 99 | 100 |
101 | 102 |
Card Error
103 |
${config.type}
104 |
${error}
105 |
106 |
107 | `; 108 | return element.firstElementChild; 109 | } 110 | }); 111 | } 112 | 113 | // Export the dependencies 114 | export { LitElement, html, css }; 115 | -------------------------------------------------------------------------------- /src/features/Pagination.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create pagination element 3 | * @param {Object} cardInstance - TodoSwipeCard instance 4 | */ 5 | export function createPagination(cardInstance) { 6 | cardInstance.paginationElement = document.createElement('div'); 7 | cardInstance.paginationElement.className = 'pagination'; 8 | 9 | for (let i = 0; i < cardInstance.cards.length; i++) { 10 | const dot = document.createElement('div'); 11 | dot.className = 'pagination-dot'; 12 | if (i === cardInstance.currentIndex) dot.classList.add('active'); 13 | 14 | // Add click handler to dots 15 | dot.addEventListener('click', () => { 16 | cardInstance.goToSlide(i); 17 | }); 18 | 19 | cardInstance.paginationElement.appendChild(dot); 20 | } 21 | 22 | cardInstance.cardContainer.appendChild(cardInstance.paginationElement); 23 | 24 | // Apply pagination styles 25 | applyPaginationStyles(cardInstance); 26 | } 27 | 28 | /** 29 | * Apply pagination-specific styles from card_mod 30 | * @param {Object} cardInstance - TodoSwipeCard instance 31 | */ 32 | export function applyPaginationStyles(cardInstance) { 33 | if (!cardInstance.paginationElement) return; 34 | 35 | // Extract pagination styling from card_mod 36 | let paginationStyles = ''; 37 | 38 | // Handle string-based card_mod style 39 | if ( 40 | cardInstance._config.card_mod && 41 | cardInstance._config.card_mod.style && 42 | typeof cardInstance._config.card_mod.style === 'string' 43 | ) { 44 | // Look for our pagination variables in the style string 45 | const styleString = cardInstance._config.card_mod.style; 46 | const variablesToExtract = [ 47 | '--todo-swipe-card-pagination-dot-inactive-color', 48 | '--todo-swipe-card-pagination-dot-active-color', 49 | '--todo-swipe-card-pagination-dot-size', 50 | '--todo-swipe-card-pagination-dot-border-radius', 51 | '--todo-swipe-card-pagination-dot-spacing', 52 | '--todo-swipe-card-pagination-bottom', 53 | '--todo-swipe-card-pagination-right', 54 | '--todo-swipe-card-pagination-background', 55 | '--todo-swipe-card-pagination-dot-active-size-multiplier', 56 | '--todo-swipe-card-pagination-dot-active-opacity', 57 | '--todo-swipe-card-pagination-dot-inactive-opacity' 58 | ]; 59 | 60 | // For each variable, try to extract its value from the style string 61 | variablesToExtract.forEach((varName) => { 62 | const regex = new RegExp(`${varName}\\s*:\\s*([^;]+)`, 'i'); 63 | const match = styleString.match(regex); 64 | if (match && match[1]) { 65 | paginationStyles += `${varName}: ${match[1].trim()};\n`; 66 | } 67 | }); 68 | } 69 | 70 | // If we found pagination styles, apply them directly to the pagination element 71 | if (paginationStyles) { 72 | cardInstance.paginationElement.style.cssText += paginationStyles; 73 | 74 | // Get all dots for individual styling 75 | const dots = cardInstance.paginationElement.querySelectorAll('.pagination-dot'); 76 | 77 | // Apply special handling for individual dot properties 78 | requestAnimationFrame(() => { 79 | dots.forEach((dot) => { 80 | // Apply base styles 81 | dot.style.borderRadius = `var(--todo-swipe-card-pagination-dot-border-radius, 50%)`; 82 | dot.style.margin = `0 var(--todo-swipe-card-pagination-dot-spacing, 4px)`; 83 | 84 | // Apply size based on active state 85 | if (dot.classList.contains('active')) { 86 | dot.style.width = `calc(var(--todo-swipe-card-pagination-dot-size, 8px) * var(--todo-swipe-card-pagination-dot-active-size-multiplier, 1))`; 87 | dot.style.height = `calc(var(--todo-swipe-card-pagination-dot-size, 8px) * var(--todo-swipe-card-pagination-dot-active-size-multiplier, 1))`; 88 | } else { 89 | dot.style.width = `var(--todo-swipe-card-pagination-dot-size, 8px)`; 90 | dot.style.height = `var(--todo-swipe-card-pagination-dot-size, 8px)`; 91 | } 92 | }); 93 | }); 94 | } 95 | } 96 | 97 | /** 98 | * Update pagination dots to reflect current slide 99 | * @param {Object} cardInstance - TodoSwipeCard instance 100 | */ 101 | export function updatePaginationDots(cardInstance) { 102 | if (cardInstance.paginationElement) { 103 | const dots = cardInstance.paginationElement.querySelectorAll('.pagination-dot'); 104 | dots.forEach((dot, i) => { 105 | dot.classList.toggle('active', i === cardInstance.currentIndex); 106 | }); 107 | 108 | // Apply pagination styles 109 | applyPaginationStyles(cardInstance); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report a reproducible issue with the Todo Swipe Card 3 | title: "[BUG]: A brief, descriptive title of the bug" 4 | labels: ["bug", "needs-triage"] 5 | assignees: 6 | - nutteloost 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thank you for reporting a bug! Please provide as much detail as possible to help us identify and fix the issue faster. 12 | 13 | - type: textarea 14 | id: current-behavior 15 | attributes: 16 | label: Current Behavior 17 | description: A clear description of what actually happens. 18 | placeholder: "When I configure X and do Y, Z happens unexpectedly." 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: expected-behavior 24 | attributes: 25 | label: Expected Behavior 26 | description: A clear description of what you expected to happen. 27 | placeholder: "I expected the card to display/behave differently." 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: steps-to-reproduce 33 | attributes: 34 | label: Steps to Reproduce 35 | description: Step-by-step instructions to reproduce the bug. 36 | placeholder: | 37 | 1. Go to '...' 38 | 2. Configure the card with: 39 | ```yaml 40 | type: custom:todo-swipe-card 41 | entities: 42 | - todo.shopping_list 43 | # Add minimal config here 44 | ``` 45 | 3. Observe the error/bug. 46 | validations: 47 | required: true 48 | 49 | - type: input 50 | id: card-version 51 | attributes: 52 | label: Todo Swipe Card Version 53 | description: What version are you using? (e.g., v3.0.0) 54 | placeholder: "e.g., v3.0.0" 55 | validations: 56 | required: true 57 | 58 | - type: input 59 | id: ha-version 60 | attributes: 61 | label: Home Assistant Version 62 | description: What version of Home Assistant? (e.g., 2023.8.1) 63 | placeholder: "e.g., 2023.8.1" 64 | validations: 65 | required: true 66 | 67 | - type: dropdown 68 | id: device-platform 69 | attributes: 70 | label: Device/Platform Running Home Assistant 71 | description: What hardware/platform is Home Assistant running on? 72 | options: 73 | - Raspberry Pi 74 | - Intel NUC 75 | - Virtual Machine (VMware/VirtualBox/etc.) 76 | - Docker Container 77 | - Physical Server/Desktop 78 | - Home Assistant Yellow 79 | - Home Assistant Green 80 | - Other 81 | default: 0 82 | validations: 83 | required: false 84 | 85 | - type: dropdown 86 | id: component 87 | attributes: 88 | label: Affected Feature 89 | description: Which feature is causing the issue? 90 | options: 91 | - General / Other 92 | - Swipe Navigation 93 | - Pagination Dots 94 | - Search Functionality 95 | - Visual Editor 96 | - Todo Operations (Add/Edit/Delete) 97 | - Background Images 98 | - CSS Styling/Variables 99 | - Entity Configuration 100 | default: 0 101 | validations: 102 | required: true 103 | 104 | - type: textarea 105 | id: yaml-configuration 106 | attributes: 107 | label: YAML Configuration 108 | description: Paste the minimal config needed to reproduce the bug. 109 | placeholder: | 110 | type: custom:todo-swipe-card 111 | entities: 112 | - todo.shopping_list 113 | # Add your config here 114 | render: yaml 115 | validations: 116 | required: true 117 | 118 | - type: textarea 119 | id: screenshots-logs 120 | attributes: 121 | label: Screenshots or Console Errors 122 | description: Add screenshots or browser console errors (F12 → Console tab). 123 | placeholder: Drag and drop images or paste console errors here. 124 | validations: 125 | required: false 126 | 127 | - type: checkboxes 128 | id: checklist 129 | attributes: 130 | label: Pre-Submission Checklist 131 | options: 132 | - label: I have searched existing [issues](https://github.com/nutteloost/todo-swipe-card/issues) to ensure this bug hasn't been reported. 133 | required: true 134 | - label: I have provided steps to reproduce the bug. 135 | required: true 136 | - label: I have included the YAML configuration to reproduce the issue. 137 | required: true -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { execSync } from 'child_process'; 4 | import fs from 'fs'; 5 | import readline from 'readline'; 6 | 7 | const rl = readline.createInterface({ 8 | input: process.stdin, 9 | output: process.stdout 10 | }); 11 | 12 | function question(query) { 13 | return new Promise(resolve => rl.question(query, resolve)); 14 | } 15 | 16 | function exec(command, options = {}) { 17 | try { 18 | console.log(`\n📝 Executing: ${command}`); 19 | const result = execSync(command, { 20 | stdio: options.silent ? 'pipe' : 'inherit', 21 | encoding: 'utf-8', 22 | ...options 23 | }); 24 | return result; 25 | } catch (error) { 26 | console.error(`\n❌ Command failed: ${command}`); 27 | throw error; 28 | } 29 | } 30 | 31 | function getCurrentVersion() { 32 | const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); 33 | return pkg.version; 34 | } 35 | 36 | function updateVersion(newVersion) { 37 | // Update package.json 38 | const pkgPath = 'package.json'; 39 | const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); 40 | pkg.version = newVersion; 41 | fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); 42 | console.log(`✅ Updated ${pkgPath} to v${newVersion}`); 43 | 44 | // Update Constants.js 45 | const constantsPath = 'src/utils/Constants.js'; 46 | let constants = fs.readFileSync(constantsPath, 'utf8'); 47 | constants = constants.replace( 48 | /export const VERSION = ["']([^"']+)["']/, 49 | `export const VERSION = '${newVersion}'` 50 | ); 51 | fs.writeFileSync(constantsPath, constants); 52 | console.log(`✅ Updated ${constantsPath} to v${newVersion}`); 53 | } 54 | 55 | function validateVersion(version) { 56 | const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/; 57 | return semverRegex.test(version); 58 | } 59 | 60 | async function main() { 61 | console.log('\n🚀 Todo Swipe Card Release Script\n'); 62 | 63 | // Check if working directory is clean 64 | try { 65 | const status = exec('git status --porcelain', { silent: true }); 66 | if (status.trim()) { 67 | console.log('⚠️ You have uncommitted changes:'); 68 | console.log(status); 69 | const proceed = await question('\nDo you want to continue anyway? (y/N): '); 70 | if (proceed.toLowerCase() !== 'y') { 71 | console.log('❌ Release cancelled'); 72 | rl.close(); 73 | process.exit(1); 74 | } 75 | } 76 | } catch (error) { 77 | console.error('❌ Error checking git status'); 78 | rl.close(); 79 | process.exit(1); 80 | } 81 | 82 | // Get current version 83 | const currentVersion = getCurrentVersion(); 84 | console.log(`📦 Current version: v${currentVersion}\n`); 85 | 86 | // Ask for new version 87 | let newVersion; 88 | while (true) { 89 | newVersion = await question(`Enter new version (e.g., ${currentVersion}): `); 90 | if (validateVersion(newVersion)) { 91 | break; 92 | } 93 | console.log('❌ Invalid version format. Use semantic versioning (e.g., 3.4.0 or 3.4.0-beta.1)'); 94 | } 95 | 96 | // Ask for release message 97 | const defaultMessage = `Release v${newVersion}`; 98 | const message = await question(`\nRelease message (default: "${defaultMessage}"): `) || defaultMessage; 99 | 100 | // Confirmation 101 | console.log('\n📋 Release Summary:'); 102 | console.log(` Version: ${currentVersion} → ${newVersion}`); 103 | console.log(` Message: ${message}`); 104 | console.log(` Tag: v${newVersion}\n`); 105 | 106 | const confirm = await question('Proceed with release? (y/N): '); 107 | if (confirm.toLowerCase() !== 'y') { 108 | console.log('❌ Release cancelled'); 109 | rl.close(); 110 | process.exit(0); 111 | } 112 | 113 | try { 114 | // Update versions 115 | console.log('\n📝 Updating version numbers...'); 116 | updateVersion(newVersion); 117 | 118 | // Run release build 119 | console.log('\n🔨 Building release...'); 120 | exec('npm run release'); 121 | 122 | // Git operations 123 | console.log('\n📦 Committing changes...'); 124 | exec('git add .'); 125 | exec(`git commit -m "${message}"`); 126 | 127 | console.log('\n🏷️ Creating tag...'); 128 | exec(`git tag v${newVersion}`); 129 | 130 | console.log('\n⬆️ Pushing to repository...'); 131 | exec('git push'); 132 | exec(`git push origin v${newVersion}`); 133 | 134 | console.log('\n✨ Release completed successfully!'); 135 | console.log(`\n🎉 Version v${newVersion} has been released!`); 136 | console.log(` - Committed with message: "${message}"`); 137 | console.log(` - Tagged as: v${newVersion}`); 138 | console.log(` - Pushed to remote repository`); 139 | console.log('\n📦 Next steps:'); 140 | console.log(' - Create GitHub release at: https://github.com/nutteloost/todo-swipe-card/releases/new?tag=v' + newVersion); 141 | console.log(' - Add release notes'); 142 | 143 | } catch (error) { 144 | console.error('\n❌ Release failed!'); 145 | console.error('You may need to manually clean up any partial changes.'); 146 | rl.close(); 147 | process.exit(1); 148 | } finally { 149 | rl.close(); 150 | } 151 | } 152 | 153 | main(); -------------------------------------------------------------------------------- /src/core/SubscriptionManager.js: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../utils/Debug.js'; 2 | import { subscribeToTodoItems } from '../features/TodoOperations.js'; 3 | 4 | /** 5 | * SubscriptionManager handles all todo subscription management for TodoSwipeCard 6 | * Manages subscriptions, updates, and cache lifecycle 7 | */ 8 | export class SubscriptionManager { 9 | constructor(cardInstance) { 10 | this.cardInstance = cardInstance; 11 | 12 | // Bind the handleTodoUpdate method for event listeners 13 | this.handleTodoUpdate = this.handleTodoUpdate.bind(this); 14 | } 15 | 16 | /** 17 | * Get hass object from card instance 18 | * @returns {Object} Home Assistant object 19 | * @private 20 | */ 21 | get _hass() { 22 | return this.cardInstance._hass; 23 | } 24 | 25 | /** 26 | * Get config object from card instance 27 | * @returns {Object} Card configuration 28 | * @private 29 | */ 30 | get _config() { 31 | return this.cardInstance._config; 32 | } 33 | 34 | /** 35 | * Initialize subscriptions when hass becomes available and cards are ready 36 | * @param {Object} hass - Home Assistant object 37 | * @param {Object} previousHass - Previous hass object 38 | */ 39 | async initializeSubscriptions(hass, previousHass) { 40 | // If not initialized yet, just store hass and return 41 | if ( 42 | !this.cardInstance.initialized || 43 | !this.cardInstance.cards || 44 | this.cardInstance.cards.length === 0 45 | ) { 46 | debugLog('Not initialized yet or no cards, storing hass and returning'); 47 | return; 48 | } 49 | 50 | // If this is the first time we get hass after cards are created, set up subscriptions 51 | if (!previousHass && this.cardInstance.cards.length > 0) { 52 | debugLog('First hass after cards created, setting up subscriptions'); 53 | 54 | for (const card of this.cardInstance.cards) { 55 | if (card && card.entityId) { 56 | debugLog(`Setting up subscription for ${card.entityId}`); 57 | 58 | // Clean up any existing subscription 59 | const existingUnsub = this.cardInstance._todoSubscriptions?.get(card.entityId); 60 | if (existingUnsub && typeof existingUnsub === 'function') { 61 | try { 62 | existingUnsub(); 63 | } catch (e) { 64 | debugLog('Error cleaning up subscription:', e); 65 | } 66 | } 67 | 68 | // Create new subscription 69 | const unsubscribe = await subscribeToTodoItems(card.entityId, this._hass); 70 | if (!this.cardInstance._todoSubscriptions) { 71 | this.cardInstance._todoSubscriptions = new Map(); 72 | } 73 | this.cardInstance._todoSubscriptions.set(card.entityId, unsubscribe); 74 | 75 | // Do initial update 76 | setTimeout(async () => { 77 | await this.cardInstance.cardBuilder.updateNativeTodoCard(card.element, card.entityId); 78 | }, 100); 79 | } 80 | } 81 | } 82 | } 83 | 84 | /** 85 | * Setup todo update event listener 86 | */ 87 | setupEventListeners() { 88 | // Add todo update listener 89 | document.addEventListener('todo-items-updated', this.handleTodoUpdate); 90 | } 91 | 92 | /** 93 | * Remove todo update event listener 94 | */ 95 | removeEventListeners() { 96 | // Remove todo update listener 97 | document.removeEventListener('todo-items-updated', this.handleTodoUpdate); 98 | } 99 | 100 | /** 101 | * Handle todo items update event 102 | * @param {CustomEvent} event - Todo update event 103 | */ 104 | handleTodoUpdate(event) { 105 | const { entityId, items } = event.detail; 106 | 107 | // Cache the items 108 | if (!this.cardInstance._todoItemsCache) { 109 | this.cardInstance._todoItemsCache = new Map(); 110 | } 111 | this.cardInstance._todoItemsCache.set(entityId, items); 112 | 113 | // Find and update the card immediately 114 | const card = this.cardInstance.cards.find((c) => c.entityId === entityId); 115 | if (card && card.element) { 116 | // Use setTimeout to ensure DOM is ready 117 | setTimeout(() => { 118 | this.cardInstance.cardBuilder.updateNativeTodoCard(card.element, entityId); 119 | }, 50); 120 | } 121 | } 122 | 123 | /** 124 | * Clean up all subscriptions and caches 125 | */ 126 | cleanup() { 127 | debugLog('SubscriptionManager performing cleanup'); 128 | 129 | // Clean up todo subscriptions 130 | if (this.cardInstance._todoSubscriptions) { 131 | this.cardInstance._todoSubscriptions.forEach(async (unsubscribe, entityId) => { 132 | try { 133 | if (typeof unsubscribe === 'function') { 134 | await Promise.resolve(unsubscribe()).catch((error) => { 135 | // Silently handle subscription cleanup errors 136 | debugLog(`Subscription cleanup error for ${entityId}:`, error); 137 | }); 138 | } 139 | } catch (e) { 140 | debugLog(`Error unsubscribing from todo entity ${entityId}:`, e); 141 | } 142 | }); 143 | this.cardInstance._todoSubscriptions.clear(); 144 | } 145 | 146 | // Clear items cache 147 | if (this.cardInstance._todoItemsCache) { 148 | this.cardInstance._todoItemsCache.clear(); 149 | } 150 | 151 | // Remove event listeners 152 | this.removeEventListeners(); 153 | 154 | debugLog('SubscriptionManager cleanup completed'); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/features/SearchFunctionality.js: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../utils/Debug.js'; 2 | 3 | /** 4 | * Setup search functionality for a card 5 | * @param {HTMLElement} cardElement - The card element 6 | * @param {string} entityId - Entity ID for the todo list 7 | * @param {Object} cardInstance - TodoSwipeCard instance 8 | */ 9 | export function setupSearchForCard(cardElement, entityId, cardInstance) { 10 | // Wait for the card to be fully rendered 11 | setTimeout(() => { 12 | enhanceSearchInputField(cardElement, entityId, cardInstance); 13 | }, 100); 14 | } 15 | 16 | /** 17 | * Enhance input field with search functionality 18 | * @param {HTMLElement} cardElement - Card element 19 | * @param {string} entityId - Entity ID 20 | * @param {Object} cardInstance - TodoSwipeCard instance 21 | */ 22 | export function enhanceSearchInputField(cardElement, entityId, cardInstance) { 23 | // Find the input element 24 | let inputElement; 25 | if (cardElement.classList.contains('todo-card-with-title-wrapper')) { 26 | inputElement = cardElement.querySelector('.native-todo-card .add-textfield input'); 27 | } else { 28 | inputElement = cardElement.querySelector('.add-textfield input'); 29 | } 30 | 31 | if (!inputElement) return; 32 | 33 | // Remove existing search handlers if any 34 | if (cardInstance._searchInputHandlers.has(entityId)) { 35 | const oldHandlers = cardInstance._searchInputHandlers.get(entityId); 36 | if (oldHandlers.inputHandler) { 37 | inputElement.removeEventListener('input', oldHandlers.inputHandler); 38 | } 39 | } 40 | 41 | // Create search input handler only 42 | const inputHandler = (e) => handleSearchInput(e, entityId, cardElement, cardInstance); 43 | 44 | // Add event listener 45 | inputElement.addEventListener('input', inputHandler); 46 | 47 | // Store handler for cleanup 48 | cardInstance._searchInputHandlers.set(entityId, { 49 | inputHandler, 50 | inputElement 51 | }); 52 | 53 | debugLog(`Search functionality setup for entity: ${entityId}`); 54 | } 55 | 56 | /** 57 | * Handle search input changes 58 | * @param {Event} e - Input event 59 | * @param {string} entityId - Entity ID 60 | * @param {HTMLElement} cardElement - Card element 61 | * @param {Object} cardInstance - TodoSwipeCard instance 62 | */ 63 | export function handleSearchInput(e, entityId, cardElement, cardInstance) { 64 | const searchText = e.target.value; 65 | cardInstance._currentSearchText = searchText; 66 | 67 | // Save search state for this entity 68 | if (searchText.trim() === '') { 69 | cardInstance._searchStates.delete(entityId); 70 | } else { 71 | cardInstance._searchStates.set(entityId, searchText); 72 | } 73 | 74 | // Update the card with filtered items 75 | cardInstance.cardBuilder.updateNativeTodoCard(cardElement, entityId); 76 | 77 | debugLog(`Search input changed for ${entityId}: "${searchText}"`); 78 | } 79 | 80 | /** 81 | * Handle keydown events in search field 82 | * @param {Event} e - Keydown event 83 | * @param {string} entityId - Entity ID 84 | * @param {HTMLElement} cardElement - Card element 85 | * @param {HTMLElement} inputElement - Input element 86 | * @param {Object} cardInstance - TodoSwipeCard instance 87 | */ 88 | export function handleSearchKeydown(e, entityId, cardElement, inputElement, cardInstance) { 89 | debugLog(`Key pressed: ${e.key} for entity: ${entityId}`); 90 | 91 | if (e.key === 'Enter') { 92 | debugLog(`Enter key detected for ${entityId}`); 93 | e.preventDefault(); 94 | e.stopPropagation(); 95 | 96 | const searchText = inputElement.value.trim(); 97 | debugLog(`Search text to process: "${searchText}"`); 98 | 99 | if (searchText) { 100 | // CLEAR SEARCH STATE FIRST - before doing anything else 101 | debugLog(`Clearing search state for ${entityId} BEFORE adding item`); 102 | cardInstance._searchStates.delete(entityId); 103 | cardInstance._currentSearchText = ''; 104 | inputElement.value = ''; 105 | 106 | debugLog( 107 | `Search state cleared. Remaining states:`, 108 | Array.from(cardInstance._searchStates.keys()) 109 | ); 110 | 111 | // Check if the search text matches any existing items 112 | const entityState = cardInstance._hass?.states?.[entityId]; 113 | const items = entityState?.attributes?.items || []; 114 | const exactMatch = items.some( 115 | (item) => item.summary.toLowerCase() === searchText.toLowerCase() 116 | ); 117 | 118 | if (!exactMatch) { 119 | debugLog(`No exact match found, adding item: "${searchText}"`); 120 | // Add new item since no exact match found 121 | cardInstance._addTodoItem(entityId, searchText); 122 | } else { 123 | debugLog(`Exact match found, not adding item: "${searchText}"`); 124 | } 125 | } 126 | } else if (e.key === 'Escape') { 127 | debugLog(`Escape key detected for ${entityId}`); 128 | // Clear search on Escape 129 | inputElement.value = ''; 130 | cardInstance._currentSearchText = ''; 131 | cardInstance._searchStates.delete(entityId); 132 | cardInstance.cardBuilder.updateNativeTodoCard(cardElement, entityId); 133 | } 134 | } 135 | 136 | /** 137 | * Check if item matches search criteria 138 | * @param {Object} item - Todo item 139 | * @param {string} searchText - Search text 140 | * @returns {boolean} True if matches 141 | */ 142 | export function matchesSearch(item, searchText) { 143 | if (!searchText) return true; 144 | 145 | try { 146 | const regex = new RegExp(searchText, 'i'); 147 | return regex.test(item.summary) || (item.description && regex.test(item.description)); 148 | } catch (e) { 149 | // Fallback to simple includes 150 | const lowerSearch = searchText.toLowerCase(); 151 | return ( 152 | item.summary.toLowerCase().includes(lowerSearch) || 153 | (item.description && item.description.toLowerCase().includes(lowerSearch)) 154 | ); 155 | } 156 | } 157 | 158 | /** 159 | * Clean up search event handlers 160 | * @param {Object} cardInstance - TodoSwipeCard instance 161 | */ 162 | export function cleanupSearchHandlers(cardInstance) { 163 | if (cardInstance._searchInputHandlers) { 164 | cardInstance._searchInputHandlers.forEach((handlers) => { 165 | if (handlers.inputElement) { 166 | if (handlers.inputHandler) { 167 | handlers.inputElement.removeEventListener('input', handlers.inputHandler); 168 | } 169 | if (handlers.keydownHandler) { 170 | handlers.inputElement.removeEventListener('keydown', handlers.keydownHandler); 171 | } 172 | } 173 | }); 174 | cardInstance._searchInputHandlers.clear(); 175 | } 176 | 177 | if (cardInstance._searchStates) { 178 | cardInstance._searchStates.clear(); 179 | } 180 | 181 | cardInstance._currentSearchText = ''; 182 | } 183 | -------------------------------------------------------------------------------- /src/ui/DomHelpers.js: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../utils/Debug.js'; 2 | 3 | /** 4 | * Build preview state 5 | * @param {DocumentFragment} fragment - Document fragment to append to 6 | */ 7 | export function buildPreview(fragment) { 8 | debugLog('Building preview state'); 9 | const previewContainer = document.createElement('div'); 10 | previewContainer.className = 'preview-container'; 11 | 12 | // Icon container 13 | const iconContainer = document.createElement('div'); 14 | iconContainer.className = 'preview-icon-container'; 15 | const icon = document.createElement('ha-icon'); 16 | icon.icon = 'mdi:format-list-checks'; 17 | iconContainer.appendChild(icon); 18 | 19 | // Text container 20 | const textContainer = document.createElement('div'); 21 | textContainer.className = 'preview-text-container'; 22 | 23 | // Title 24 | const title = document.createElement('div'); 25 | title.className = 'preview-title'; 26 | title.textContent = 'Todo Swipe Card'; 27 | 28 | // Description 29 | const description = document.createElement('div'); 30 | description.className = 'preview-description'; 31 | description.textContent = 32 | 'A specialized swipe card for todo lists with native styling. Supports multiple lists with swipe navigation.'; 33 | 34 | textContainer.appendChild(title); 35 | textContainer.appendChild(description); 36 | 37 | // Button 38 | const actionsContainer = document.createElement('div'); 39 | actionsContainer.className = 'preview-actions'; 40 | const editButton = document.createElement('ha-button'); 41 | editButton.raised = true; 42 | editButton.textContent = 'EDIT CARD'; 43 | editButton.setAttribute('aria-label', 'Edit Card'); 44 | editButton.addEventListener('click', (e) => { 45 | e.stopPropagation(); 46 | debugLog('Edit button clicked, firing show-edit-card event'); 47 | const event = new CustomEvent('show-edit-card', { 48 | detail: { element: e.target.closest('todo-swipe-card') }, 49 | bubbles: true, 50 | composed: true 51 | }); 52 | e.target.dispatchEvent(event); 53 | }); 54 | actionsContainer.appendChild(editButton); 55 | 56 | // Append all elements 57 | previewContainer.appendChild(iconContainer); 58 | previewContainer.appendChild(textContainer); 59 | previewContainer.appendChild(actionsContainer); 60 | 61 | fragment.appendChild(previewContainer); 62 | } 63 | 64 | /** 65 | * Create a wrapper around the card with title 66 | * @param {HTMLElement} cardElement - The card element 67 | * @param {string} titleText - The title text 68 | * @returns {HTMLElement} Wrapper element containing title and card 69 | */ 70 | export function createCardWithTitle(cardElement, titleText) { 71 | // Create wrapper 72 | const wrapper = document.createElement('div'); 73 | wrapper.className = 'todo-card-with-title-wrapper'; 74 | 75 | // Create title 76 | const titleElement = document.createElement('div'); 77 | titleElement.className = 'todo-swipe-card-external-title'; 78 | titleElement.textContent = titleText; 79 | 80 | // Create card container 81 | const cardContainer = document.createElement('div'); 82 | cardContainer.className = 'todo-card-container'; 83 | 84 | // Assemble 85 | wrapper.appendChild(titleElement); 86 | cardContainer.appendChild(cardElement); 87 | wrapper.appendChild(cardContainer); 88 | 89 | return wrapper; 90 | } 91 | 92 | /** 93 | * Create an icon element for the slide 94 | * @param {string|Object} entityConfig - Entity configuration 95 | * @param {string} entityId - Entity ID 96 | * @param {Object} hass - Home Assistant object 97 | * @returns {HTMLElement} Icon element 98 | */ 99 | export function createIconElement(entityConfig, entityId, hass) { 100 | // Determine which icon to use 101 | let iconName = 'mdi:format-list-checks'; // Default fallback icon 102 | 103 | // Check for custom icon in entity config 104 | if (typeof entityConfig === 'object' && entityConfig.icon) { 105 | iconName = entityConfig.icon; 106 | } else if (hass && hass.states[entityId]) { 107 | // Use entity's default icon if available 108 | const entityIcon = hass.states[entityId].attributes.icon; 109 | if (entityIcon) { 110 | iconName = entityIcon; 111 | } 112 | } 113 | 114 | // Create icon element 115 | const iconElement = document.createElement('ha-icon'); 116 | iconElement.className = 'todo-icon'; 117 | iconElement.icon = iconName; 118 | 119 | return iconElement; 120 | } 121 | 122 | /** 123 | * Create due date element with live updates 124 | * @param {string} due - Due date string 125 | * @returns {HTMLElement} Due date element 126 | */ 127 | export function createDueDateElement(due) { 128 | const dueElement = document.createElement('div'); 129 | const dueDate = parseDueDate(due); 130 | const now = new Date(); 131 | const isOverdue = dueDate && dueDate < now; 132 | 133 | dueElement.className = `todo-due ${isOverdue ? 'overdue' : ''}`; 134 | 135 | // Create clock icon using ha-svg-icon (like the official card) 136 | const icon = document.createElement('ha-svg-icon'); 137 | icon.className = 'todo-due-icon'; 138 | icon.path = 139 | 'M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.7L16.2,16.2Z'; 140 | dueElement.appendChild(icon); 141 | 142 | // Create date text 143 | if (dueDate) { 144 | const isDateOnly = !due.includes('T'); 145 | const isToday = isDateOnly && isSameDay(new Date(), dueDate); 146 | 147 | if (isToday) { 148 | // For "today", use plain text since it doesn't need live updates 149 | const dateText = document.createElement('span'); 150 | dateText.textContent = 'Today'; 151 | dueElement.appendChild(dateText); 152 | } else { 153 | // Check if it's a short interval (less than 1 hour) 154 | const timeDiff = Math.abs(dueDate.getTime() - now.getTime()); 155 | const oneHour = 60 * 60 * 1000; 156 | 157 | if (timeDiff < oneHour) { 158 | // Use custom live updater for short intervals 159 | const dateText = document.createElement('span'); 160 | dueElement.appendChild(dateText); 161 | 162 | // Update function 163 | const updateTime = () => { 164 | const currentTime = new Date(); 165 | const diffMs = dueDate.getTime() - currentTime.getTime(); 166 | 167 | // Update overdue class 168 | const isCurrentlyOverdue = diffMs < 0; 169 | dueElement.classList.toggle('overdue', isCurrentlyOverdue); 170 | 171 | // Format the time 172 | if (Math.abs(diffMs) < 60000) { 173 | // Less than 1 minute - show seconds 174 | const seconds = Math.round(Math.abs(diffMs) / 1000); 175 | if (seconds < 5) { 176 | dateText.textContent = 'now'; 177 | } else if (diffMs < 0) { 178 | dateText.textContent = `${seconds} seconds ago`; 179 | } else { 180 | dateText.textContent = `in ${seconds} seconds`; 181 | } 182 | } else { 183 | // More than 1 minute - show minutes 184 | const minutes = Math.round(Math.abs(diffMs) / 60000); 185 | if (diffMs < 0) { 186 | dateText.textContent = `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; 187 | } else { 188 | dateText.textContent = `in ${minutes} minute${minutes !== 1 ? 's' : ''}`; 189 | } 190 | } 191 | }; 192 | 193 | // Initial update 194 | updateTime(); 195 | 196 | // Set up interval for live updates 197 | const interval = setInterval(updateTime, 1000); // Update every second 198 | 199 | // Clean up interval when element is removed 200 | const observer = new MutationObserver((mutations) => { 201 | mutations.forEach((mutation) => { 202 | if (mutation.type === 'childList') { 203 | mutation.removedNodes.forEach((node) => { 204 | if (node === dueElement || node.contains?.(dueElement)) { 205 | clearInterval(interval); 206 | observer.disconnect(); 207 | } 208 | }); 209 | } 210 | }); 211 | }); 212 | 213 | // Start observing 214 | if (dueElement.parentNode) { 215 | observer.observe(dueElement.parentNode, { childList: true, subtree: true }); 216 | } 217 | } else { 218 | // For longer intervals, use ha-relative-time 219 | const relativeTime = document.createElement('ha-relative-time'); 220 | relativeTime.setAttribute('capitalize', ''); 221 | relativeTime.datetime = dueDate; 222 | dueElement.appendChild(relativeTime); 223 | } 224 | } 225 | } else { 226 | // Fallback to raw string if parsing failed 227 | const dateText = document.createElement('span'); 228 | dateText.textContent = due; 229 | dueElement.appendChild(dateText); 230 | } 231 | 232 | return dueElement; 233 | } 234 | 235 | /** 236 | * Parse due date string to Date object (following HA's logic) 237 | * @param {string} due - Due date string 238 | * @returns {Date|null} Parsed date or null 239 | */ 240 | export function parseDueDate(due) { 241 | if (!due) return null; 242 | 243 | try { 244 | if (due.includes('T')) { 245 | // DateTime - use exact time 246 | return new Date(due); 247 | } else { 248 | // Date only - set to end of day (like HA does) 249 | const date = new Date(`${due}T00:00:00`); 250 | // Set to end of day for proper "today" comparison 251 | date.setHours(23, 59, 59, 999); 252 | return isNaN(date.getTime()) ? null : date; 253 | } 254 | } catch (e) { 255 | return null; 256 | } 257 | } 258 | 259 | /** 260 | * Check if two dates are the same day 261 | * @param {Date} date1 - First date 262 | * @param {Date} date2 - Second date 263 | * @returns {boolean} True if same day 264 | */ 265 | export function isSameDay(date1, date2) { 266 | return ( 267 | date1.getFullYear() === date2.getFullYear() && 268 | date1.getMonth() === date2.getMonth() && 269 | date1.getDate() === date2.getDate() 270 | ); 271 | } 272 | 273 | /** 274 | * Format relative time for due dates (enhanced for better granularity) 275 | * @param {Date} date - Date to format 276 | * @returns {string} Formatted relative time 277 | */ 278 | export function formatRelativeTime(date) { 279 | const now = new Date(); 280 | const diffMs = date.getTime() - now.getTime(); 281 | const diffMinutes = Math.round(diffMs / (1000 * 60)); 282 | const diffHours = Math.round(diffMs / (1000 * 60 * 60)); 283 | const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); 284 | 285 | // Handle very precise timing (like HA does) 286 | if (Math.abs(diffMs) < 30000) { 287 | // Less than 30 seconds 288 | return 'now'; 289 | } else if (Math.abs(diffMs) < 60000) { 290 | // Less than 1 minute 291 | const seconds = Math.round(Math.abs(diffMs) / 1000); 292 | if (diffMs < 0) { 293 | return `${seconds} seconds ago`; 294 | } else { 295 | return `in ${seconds} seconds`; 296 | } 297 | } else if (Math.abs(diffMinutes) < 60) { 298 | // Less than 1 hour 299 | if (diffMinutes < 0) { 300 | return `${Math.abs(diffMinutes)} minutes ago`; 301 | } else { 302 | return `in ${diffMinutes} minutes`; 303 | } 304 | } else if (Math.abs(diffHours) < 24) { 305 | // Less than 1 day 306 | if (diffHours < 0) { 307 | return `${Math.abs(diffHours)} hours ago`; 308 | } else { 309 | return `in ${diffHours} hours`; 310 | } 311 | } else { 312 | // Fall back to day-based relative time 313 | if (diffDays === 1) { 314 | return 'Tomorrow'; 315 | } else if (diffDays === -1) { 316 | return 'Yesterday'; 317 | } else if (diffDays > 0) { 318 | return `in ${diffDays} days`; 319 | } else { 320 | return `${Math.abs(diffDays)} days ago`; 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/features/SwipeGestures.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add swipe gesture handling with touch and mouse support 3 | * 4 | * Gesture Detection Strategy: 5 | * 1. Detect if touch/click starts on interactive elements (inputs, buttons, etc.) 6 | * 2. Distinguish between scroll intent (vertical) vs swipe intent (horizontal) 7 | * 3. Only enable drag mode after confirming horizontal swipe intention 8 | * 4. Apply resistance at card boundaries to prevent overscroll 9 | * 10 | * @param {Object} cardInstance - TodoSwipeCard instance 11 | */ 12 | export function addSwipeGestures(cardInstance) { 13 | // Clean up any existing listeners to prevent duplicates on rebuild 14 | if (cardInstance._touchStartHandler) { 15 | cardInstance.cardContainer.removeEventListener('touchstart', cardInstance._touchStartHandler); 16 | cardInstance.cardContainer.removeEventListener('touchmove', cardInstance._touchMoveHandler); 17 | cardInstance.cardContainer.removeEventListener('touchend', cardInstance._touchEndHandler); 18 | cardInstance.cardContainer.removeEventListener('touchcancel', cardInstance._touchEndHandler); 19 | cardInstance.cardContainer.removeEventListener('mousedown', cardInstance._mouseDownHandler); 20 | window.removeEventListener('mousemove', cardInstance._mouseMoveHandler); 21 | window.removeEventListener('mouseup', cardInstance._mouseUpHandler); 22 | } 23 | 24 | // Gesture state variables 25 | let startX = 0; // Initial touch/click X position 26 | let startY = 0; // Initial touch/click Y position 27 | let currentX = 0; // Current drag X position 28 | let isDragging = false; // True when actively dragging slides 29 | let isScrolling = false; // True when user intends to scroll vertically 30 | let initialTransform = 0; // Starting slider transform value 31 | let isInteractiveElement = false; // True if gesture started on input/button 32 | let swipeIntentionConfirmed = false; // True when horizontal intent confirmed 33 | 34 | /** 35 | * Enhanced interactive element detection 36 | * Checks element hierarchy to identify inputs, buttons, and other interactive components 37 | * NOTE: Does NOT block scrollable elements - scroll vs swipe is determined dynamically 38 | * 39 | * @param {Element} element - Element to check 40 | * @returns {boolean} True if element is interactive 41 | */ 42 | cardInstance._isInteractiveOrScrollable = (element) => { 43 | if ( 44 | !element || 45 | element === cardInstance.cardContainer || 46 | element === cardInstance.sliderElement 47 | ) 48 | return false; 49 | 50 | let current = element; 51 | let depth = 0; 52 | 53 | // Walk up the DOM tree to check for interactive elements 54 | while (current && depth < 15) { 55 | try { 56 | if (current.nodeType === Node.ELEMENT_NODE) { 57 | const tagName = current.localName?.toLowerCase(); 58 | const role = current.getAttribute && current.getAttribute('role'); 59 | 60 | // Check for interactive HTML tags 61 | const interactiveTags = [ 62 | 'input', 63 | 'textarea', 64 | 'select', 65 | 'button', 66 | 'a', 67 | 'ha-switch', 68 | 'ha-checkbox', 69 | 'mwc-checkbox', 70 | 'paper-checkbox', 71 | 'ha-textfield', 72 | 'ha-slider', 73 | 'paper-slider', 74 | 'ha-icon-button', 75 | 'mwc-button', 76 | 'paper-button' 77 | ]; 78 | 79 | if (interactiveTags.includes(tagName)) { 80 | return true; 81 | } 82 | 83 | // Check ARIA roles for interactive elements 84 | if ( 85 | role && 86 | [ 87 | 'button', 88 | 'checkbox', 89 | 'switch', 90 | 'slider', 91 | 'link', 92 | 'menuitem', 93 | 'textbox', 94 | 'input', 95 | 'combobox', 96 | 'searchbox' 97 | ].includes(role) 98 | ) { 99 | return true; 100 | } 101 | 102 | // Check for Material Design Component classes 103 | if (current.classList) { 104 | const mdcClasses = [ 105 | 'mdc-text-field', 106 | 'mdc-text-field__input', 107 | 'mdc-text-field__ripple', 108 | 'mdc-line-ripple', 109 | 'mdc-floating-label', 110 | 'mdc-text-field__affix' 111 | ]; 112 | for (const className of mdcClasses) { 113 | if (current.classList.contains(className)) { 114 | return true; 115 | } 116 | } 117 | } 118 | } 119 | } catch (e) { 120 | break; // Exit on any DOM traversal errors 121 | } 122 | 123 | // Move up the DOM tree (handle shadow DOM and slots) 124 | current = 125 | current.assignedSlot || 126 | current.parentNode || 127 | (current.getRootNode && current.getRootNode().host); 128 | depth++; 129 | } 130 | 131 | return false; 132 | }; 133 | 134 | /** 135 | * Handle gesture start (touchstart/mousedown) 136 | * Determines if this gesture should be processed and initializes tracking 137 | */ 138 | cardInstance._handleSwipeStart = (e) => { 139 | // Ignore non-primary mouse buttons or if already dragging 140 | if (isDragging || (e.type === 'mousedown' && e.button !== 0)) return; 141 | 142 | // CRITICAL: Check for interactive elements first - if found, skip ALL swipe processing 143 | isInteractiveElement = cardInstance._isInteractiveOrScrollable(e.target); 144 | if (isInteractiveElement) { 145 | return; // Exit immediately for inputs, buttons, etc. 146 | } 147 | 148 | // Initialize gesture state (but don't start dragging yet) 149 | isDragging = false; 150 | isScrolling = false; 151 | swipeIntentionConfirmed = false; 152 | 153 | // Record starting position 154 | if (e.type === 'touchstart') { 155 | startX = e.touches[0].clientX; 156 | startY = e.touches[0].clientY; 157 | } else { 158 | startX = e.clientX; 159 | startY = e.clientY; 160 | } 161 | currentX = startX; 162 | 163 | // Capture initial slider position for relative dragging 164 | if (cardInstance.sliderElement) { 165 | const style = window.getComputedStyle(cardInstance.sliderElement); 166 | const matrix = new DOMMatrixReadOnly(style.transform); 167 | initialTransform = matrix.m41; // X translation value 168 | } 169 | 170 | // For mouse events, add window listeners to track movement outside element 171 | if (e.type === 'mousedown') { 172 | window.addEventListener('mousemove', cardInstance._mouseMoveHandler); 173 | window.addEventListener('mouseup', cardInstance._mouseUpHandler); 174 | } 175 | }; 176 | 177 | /** 178 | * Handle gesture movement (touchmove/mousemove) 179 | * Determines scroll vs swipe intention and handles drag calculations 180 | */ 181 | cardInstance._handleSwipeMove = (e) => { 182 | // Skip if gesture started on interactive element 183 | if (isInteractiveElement) return; 184 | 185 | let clientX, clientY; 186 | if (e.type === 'touchmove') { 187 | clientX = e.touches[0].clientX; 188 | clientY = e.touches[0].clientY; 189 | } else { 190 | clientX = e.clientX; 191 | clientY = e.clientY; 192 | } 193 | 194 | const moveX = clientX - startX; 195 | const moveY = clientY - startY; 196 | 197 | // INTENTION DETECTION: Determine if user wants to scroll or swipe 198 | if (!isScrolling && !swipeIntentionConfirmed) { 199 | // Vertical movement dominates = user wants to scroll 200 | if (Math.abs(moveY) > Math.abs(moveX) && Math.abs(moveY) > 15) { 201 | isScrolling = true; 202 | return; // Let browser handle scrolling 203 | } 204 | // Horizontal movement dominates = user wants to swipe 205 | else if (Math.abs(moveX) > 15) { 206 | swipeIntentionConfirmed = true; 207 | isDragging = true; 208 | 209 | // NOW we commit to swipe mode - disable transitions and set cursor 210 | if (cardInstance.sliderElement) { 211 | cardInstance.sliderElement.style.transition = 'none'; 212 | cardInstance.sliderElement.style.cursor = 'grabbing'; 213 | } 214 | 215 | // Prevent default scrolling behavior 216 | if (e.cancelable) { 217 | e.preventDefault(); 218 | } 219 | } else { 220 | // Movement too small - keep monitoring 221 | return; 222 | } 223 | } 224 | 225 | // Skip processing if user is scrolling 226 | if (isScrolling || !swipeIntentionConfirmed) return; 227 | 228 | // Prevent browser default behaviors for confirmed swipes 229 | if (e.cancelable) { 230 | e.preventDefault(); 231 | } 232 | 233 | currentX = clientX; 234 | 235 | // DRAG CALCULATION with boundary resistance 236 | let totalDragOffset = currentX - startX; 237 | 238 | // Apply resistance at edges to prevent overscroll 239 | const atLeftEdge = cardInstance.currentIndex === 0; 240 | const atRightEdge = cardInstance.currentIndex === cardInstance.cards.length - 1; 241 | 242 | if ((atLeftEdge && totalDragOffset > 0) || (atRightEdge && totalDragOffset < 0)) { 243 | const overDrag = Math.abs(totalDragOffset); 244 | // Exponential resistance curve - more resistance as user drags further 245 | const resistanceFactor = 0.3 + 0.7 / (1 + overDrag / (cardInstance.slideWidth * 0.5)); 246 | totalDragOffset *= resistanceFactor * 0.5; 247 | } 248 | 249 | // Apply transform using requestAnimationFrame for smooth performance 250 | const newTransform = initialTransform + totalDragOffset; 251 | if (cardInstance.sliderElement) { 252 | requestAnimationFrame(() => { 253 | cardInstance.sliderElement.style.transform = `translateX(${newTransform}px)`; 254 | }); 255 | } 256 | }; 257 | 258 | /** 259 | * Handle gesture end (touchend/mouseup/touchcancel) 260 | * Determines if drag was significant enough to change slides and animates to final position 261 | */ 262 | cardInstance._handleSwipeEnd = (e) => { 263 | // Clean up window event listeners for mouse events 264 | if (e.type === 'mouseup' || e.type === 'mouseleave') { 265 | window.removeEventListener('mousemove', cardInstance._mouseMoveHandler); 266 | window.removeEventListener('mouseup', cardInstance._mouseUpHandler); 267 | } 268 | 269 | // Skip if gesture started on interactive element 270 | if (isInteractiveElement) { 271 | isInteractiveElement = false; 272 | return; 273 | } 274 | 275 | const wasDragging = isDragging; 276 | 277 | // Reset all gesture state 278 | isDragging = false; 279 | isScrolling = false; 280 | swipeIntentionConfirmed = false; 281 | isInteractiveElement = false; 282 | 283 | // Restore normal slider behavior 284 | if (cardInstance.sliderElement) { 285 | const transitionSpeed = cardInstance._transitionSpeed || '0.3s'; 286 | const transitionEasing = cardInstance._transitionEasing || 'ease-out'; 287 | cardInstance.sliderElement.style.transition = `transform ${transitionSpeed} ${transitionEasing}`; 288 | cardInstance.sliderElement.style.cursor = ''; 289 | } 290 | 291 | // Only process slide change if we were actually dragging and it wasn't cancelled 292 | if (!wasDragging || e.type === 'touchcancel') { 293 | cardInstance.updateSlider(); // Snap back to current position 294 | return; 295 | } 296 | 297 | // SLIDE CHANGE LOGIC: Check if drag distance exceeds threshold 298 | const totalMoveX = currentX - startX; 299 | const threshold = cardInstance.slideWidth * 0.2; // 20% of slide width 300 | 301 | if (Math.abs(totalMoveX) > threshold) { 302 | // Drag right and not at first slide = go to previous slide 303 | if (totalMoveX > 0 && cardInstance.currentIndex > 0) { 304 | cardInstance.currentIndex--; 305 | } 306 | // Drag left and not at last slide = go to next slide 307 | else if (totalMoveX < 0 && cardInstance.currentIndex < cardInstance.cards.length - 1) { 308 | cardInstance.currentIndex++; 309 | } 310 | } 311 | 312 | // Animate to final position 313 | cardInstance.updateSlider(true); 314 | }; 315 | 316 | // Store bound handlers for cleanup 317 | cardInstance._touchStartHandler = cardInstance._handleSwipeStart.bind(cardInstance); 318 | cardInstance._touchMoveHandler = cardInstance._handleSwipeMove.bind(cardInstance); 319 | cardInstance._touchEndHandler = cardInstance._handleSwipeEnd.bind(cardInstance); 320 | cardInstance._mouseDownHandler = cardInstance._handleSwipeStart.bind(cardInstance); 321 | cardInstance._mouseMoveHandler = cardInstance._handleSwipeMove.bind(cardInstance); 322 | cardInstance._mouseUpHandler = cardInstance._handleSwipeEnd.bind(cardInstance); 323 | 324 | // Attach event listeners 325 | cardInstance.cardContainer.addEventListener('touchstart', cardInstance._touchStartHandler, { 326 | passive: true 327 | }); 328 | cardInstance.cardContainer.addEventListener('touchmove', cardInstance._touchMoveHandler, { 329 | passive: false 330 | }); 331 | cardInstance.cardContainer.addEventListener('touchend', cardInstance._touchEndHandler, { 332 | passive: true 333 | }); 334 | cardInstance.cardContainer.addEventListener('touchcancel', cardInstance._touchEndHandler, { 335 | passive: true 336 | }); 337 | cardInstance.cardContainer.addEventListener('mousedown', cardInstance._mouseDownHandler); 338 | } 339 | -------------------------------------------------------------------------------- /src/features/DragDrop.js: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../utils/Debug.js'; 2 | 3 | /** 4 | * Setup drag and drop functionality for todo items 5 | * Mobile: Uses simple approach (HTML5 drag events only, doesn't work but doesn't interfere) 6 | * Desktop: Uses press-and-hold activation to avoid conflict with swipe gestures 7 | * @param {HTMLElement} listContainer - The todo list container 8 | * @param {string} entityId - Entity ID 9 | * @param {Array} items - Array of todo items 10 | * @param {Object} hass - Home Assistant object 11 | */ 12 | export function setupDragAndDrop(listContainer, entityId, items, hass) { 13 | const todoItems = listContainer.querySelectorAll('.todo-item[data-supports-drag="true"]'); 14 | 15 | // Track which item is currently showing drop indicator 16 | let currentDropTarget = null; 17 | let currentDropPosition = null; 18 | 19 | // Detect if this is a mobile device 20 | const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0; 21 | 22 | if (isMobile) { 23 | // MOBILE: Use original simple approach - just set draggable and add drag events 24 | // (HTML5 drag/drop doesn't work on mobile, but this approach doesn't interfere with scrolling) 25 | debugLog( 26 | 'Mobile device detected - using simple drag setup (drag/drop not supported on mobile)' 27 | ); 28 | 29 | todoItems.forEach((itemElement, index) => { 30 | const item = items[index]; 31 | if (!item) return; 32 | 33 | // Set draggable from the start (original approach) 34 | itemElement.setAttribute('draggable', 'true'); 35 | 36 | // Add only the drag event listeners (won't fire on mobile, but doesn't interfere) 37 | setupDragEventListeners( 38 | itemElement, 39 | item, 40 | todoItems, 41 | listContainer, 42 | currentDropTarget, 43 | currentDropPosition, 44 | entityId, 45 | items, 46 | hass 47 | ); 48 | }); 49 | 50 | return; // Exit early for mobile 51 | } 52 | 53 | // DESKTOP: Use press-and-hold activation to avoid swipe gesture conflicts 54 | debugLog('Desktop device detected - using hold-to-drag setup'); 55 | 56 | const HOLD_DURATION = 200; // 0.2 seconds to activate drag 57 | const MOVE_TOLERANCE = 8; // 8px of movement cancels hold 58 | 59 | todoItems.forEach((itemElement, index) => { 60 | const item = items[index]; 61 | if (!item) return; 62 | 63 | // Start with dragging DISABLED - requires hold to activate 64 | itemElement.setAttribute('draggable', 'false'); 65 | 66 | // Track hold state for this item 67 | let holdTimer = null; 68 | let holdStartX = 0; 69 | let holdStartY = 0; 70 | let dragEnabled = false; 71 | let isDuringHoldPeriod = false; 72 | 73 | // Mouse down - start hold timer 74 | const handlePointerDown = (e) => { 75 | // Don't activate on checkbox 76 | if (e.target.closest('ha-checkbox') || e.target.closest('.todo-checkbox')) { 77 | return; 78 | } 79 | 80 | holdStartX = e.clientX; 81 | holdStartY = e.clientY; 82 | dragEnabled = false; 83 | isDuringHoldPeriod = true; 84 | 85 | holdTimer = setTimeout(() => { 86 | dragEnabled = true; 87 | isDuringHoldPeriod = false; 88 | itemElement.setAttribute('draggable', 'true'); 89 | itemElement.classList.add('drag-ready'); 90 | 91 | if (navigator.vibrate) { 92 | navigator.vibrate(50); 93 | } 94 | 95 | debugLog(`✓ Drag ENABLED after hold: ${item.summary}`); 96 | }, HOLD_DURATION); 97 | 98 | debugLog(`⏱ Hold timer started for: ${item.summary}`); 99 | }; 100 | 101 | // Mouse move - cancel hold if moved too much 102 | const handlePointerMove = (e) => { 103 | if (!isDuringHoldPeriod || !holdTimer) return; 104 | 105 | const deltaX = Math.abs(e.clientX - holdStartX); 106 | const deltaY = Math.abs(e.clientY - holdStartY); 107 | 108 | if (deltaX > MOVE_TOLERANCE || deltaY > MOVE_TOLERANCE) { 109 | clearTimeout(holdTimer); 110 | holdTimer = null; 111 | isDuringHoldPeriod = false; 112 | debugLog(`✗ Hold CANCELLED - movement detected: ${item.summary}`); 113 | } 114 | }; 115 | 116 | // Mouse up or leave - cancel hold timer OR reset drag state 117 | const handlePointerUp = (e) => { 118 | const isMouseLeave = e.type === 'mouseleave'; 119 | const isMouseUp = e.type === 'mouseup'; 120 | 121 | if (isDuringHoldPeriod && holdTimer) { 122 | if (isMouseLeave) { 123 | debugLog(`ℹ Ignoring mouseleave during hold period: ${item.summary}`); 124 | return; 125 | } 126 | 127 | if (isMouseUp) { 128 | clearTimeout(holdTimer); 129 | holdTimer = null; 130 | isDuringHoldPeriod = false; 131 | debugLog(`✗ Hold CANCELLED - button released too early: ${item.summary}`); 132 | return; 133 | } 134 | } 135 | 136 | if (itemElement.classList.contains('dragging')) { 137 | debugLog(`ℹ Drag in progress - ignoring pointer event: ${item.summary}`); 138 | return; 139 | } 140 | 141 | if (dragEnabled) { 142 | if (isMouseLeave) { 143 | debugLog(`ℹ Ignoring mouseleave - drag is enabled: ${item.summary}`); 144 | return; 145 | } 146 | 147 | if (isMouseUp) { 148 | debugLog(`🧹 Button released without drag - cleaning up: ${item.summary}`); 149 | itemElement.setAttribute('draggable', 'false'); 150 | itemElement.classList.remove('drag-ready'); 151 | dragEnabled = false; 152 | isDuringHoldPeriod = false; 153 | return; 154 | } 155 | } 156 | 157 | setTimeout(() => { 158 | if (!itemElement.classList.contains('dragging') && !dragEnabled) { 159 | itemElement.setAttribute('draggable', 'false'); 160 | itemElement.classList.remove('drag-ready'); 161 | dragEnabled = false; 162 | isDuringHoldPeriod = false; 163 | } 164 | }, 100); 165 | }; 166 | 167 | // Attach hold detection listeners (desktop only) 168 | itemElement.addEventListener('mousedown', handlePointerDown); 169 | itemElement.addEventListener('mousemove', handlePointerMove); 170 | itemElement.addEventListener('mouseup', handlePointerUp); 171 | itemElement.addEventListener('mouseleave', handlePointerUp); 172 | 173 | // Drag start - check if properly enabled on desktop 174 | itemElement.addEventListener('dragstart', (e) => { 175 | if (e.target.closest('ha-checkbox') || e.target.closest('.todo-checkbox')) { 176 | e.preventDefault(); 177 | return; 178 | } 179 | 180 | if (!dragEnabled) { 181 | e.preventDefault(); 182 | debugLog(`✗ Drag PREVENTED - hold not completed: ${item.summary}`); 183 | return; 184 | } 185 | 186 | itemElement.classList.add('dragging'); 187 | itemElement.classList.remove('drag-ready'); 188 | e.dataTransfer.effectAllowed = 'move'; 189 | e.dataTransfer.setData('text/plain', item.uid); 190 | 191 | const dragImage = document.createElement('div'); 192 | dragImage.style.cssText = 193 | 'position: absolute; top: -1000px; width: 1px; height: 1px; opacity: 0;'; 194 | document.body.appendChild(dragImage); 195 | e.dataTransfer.setDragImage(dragImage, 0, 0); 196 | 197 | setTimeout(() => { 198 | document.body.removeChild(dragImage); 199 | }, 0); 200 | 201 | debugLog(`🎯 Drag STARTED: ${item.summary}`); 202 | }); 203 | 204 | // Drag end 205 | itemElement.addEventListener('dragend', () => { 206 | itemElement.classList.remove('dragging', 'drag-ready'); 207 | itemElement.setAttribute('draggable', 'false'); 208 | dragEnabled = false; 209 | isDuringHoldPeriod = false; 210 | 211 | todoItems.forEach((el) => el.classList.remove('drag-over-top', 'drag-over-bottom')); 212 | currentDropTarget = null; 213 | currentDropPosition = null; 214 | 215 | debugLog(`✓ Drag ENDED: ${item.summary}`); 216 | }); 217 | 218 | // Add dragover and drop listeners 219 | setupDragOverAndDrop( 220 | itemElement, 221 | item, 222 | todoItems, 223 | listContainer, 224 | currentDropTarget, 225 | currentDropPosition, 226 | entityId, 227 | items, 228 | hass 229 | ); 230 | }); 231 | } 232 | 233 | /** 234 | * Setup drag event listeners (used for both mobile and desktop) 235 | */ 236 | function setupDragEventListeners( 237 | itemElement, 238 | item, 239 | todoItems, 240 | listContainer, 241 | currentDropTarget, 242 | currentDropPosition, 243 | entityId, 244 | items, 245 | hass 246 | ) { 247 | // Drag start (mobile/simple version - no hold check) 248 | itemElement.addEventListener('dragstart', (e) => { 249 | if (e.target.closest('ha-checkbox') || e.target.closest('.todo-checkbox')) { 250 | e.preventDefault(); 251 | return; 252 | } 253 | 254 | itemElement.classList.add('dragging'); 255 | e.dataTransfer.effectAllowed = 'move'; 256 | e.dataTransfer.setData('text/plain', item.uid); 257 | 258 | const dragImage = document.createElement('div'); 259 | dragImage.style.cssText = 260 | 'position: absolute; top: -1000px; width: 1px; height: 1px; opacity: 0;'; 261 | document.body.appendChild(dragImage); 262 | e.dataTransfer.setDragImage(dragImage, 0, 0); 263 | 264 | setTimeout(() => { 265 | document.body.removeChild(dragImage); 266 | }, 0); 267 | 268 | debugLog(`Drag started for item: ${item.summary}`); 269 | }); 270 | 271 | // Drag end 272 | itemElement.addEventListener('dragend', () => { 273 | itemElement.classList.remove('dragging'); 274 | todoItems.forEach((el) => el.classList.remove('drag-over-top', 'drag-over-bottom')); 275 | debugLog(`Drag ended for item: ${item.summary}`); 276 | }); 277 | 278 | // Dragover and drop 279 | setupDragOverAndDrop( 280 | itemElement, 281 | item, 282 | todoItems, 283 | listContainer, 284 | currentDropTarget, 285 | currentDropPosition, 286 | entityId, 287 | items, 288 | hass 289 | ); 290 | } 291 | 292 | /** 293 | * Setup dragover and drop listeners (shared between mobile and desktop) 294 | */ 295 | function setupDragOverAndDrop( 296 | itemElement, 297 | item, 298 | todoItems, 299 | listContainer, 300 | currentDropTarget, 301 | currentDropPosition, 302 | entityId, 303 | items, 304 | hass 305 | ) { 306 | // Drag over 307 | itemElement.addEventListener('dragover', (e) => { 308 | e.preventDefault(); 309 | e.dataTransfer.dropEffect = 'move'; 310 | 311 | const draggingElement = listContainer.querySelector('.dragging'); 312 | if (!draggingElement || draggingElement === itemElement) { 313 | return; 314 | } 315 | 316 | const rect = itemElement.getBoundingClientRect(); 317 | const midpoint = rect.top + rect.height / 2; 318 | const newPosition = e.clientY < midpoint ? 'top' : 'bottom'; 319 | 320 | todoItems.forEach((el) => el.classList.remove('drag-over-top', 'drag-over-bottom')); 321 | 322 | if (newPosition === 'top') { 323 | itemElement.classList.add('drag-over-top'); 324 | } else { 325 | itemElement.classList.add('drag-over-bottom'); 326 | } 327 | }); 328 | 329 | // Drop 330 | itemElement.addEventListener('drop', async (e) => { 331 | e.preventDefault(); 332 | e.stopPropagation(); 333 | 334 | const draggingElement = listContainer.querySelector('.dragging'); 335 | if (!draggingElement || draggingElement === itemElement) { 336 | todoItems.forEach((el) => el.classList.remove('drag-over-top', 'drag-over-bottom')); 337 | return; 338 | } 339 | 340 | const draggedUid = e.dataTransfer.getData('text/plain'); 341 | const draggedItem = items.find((item) => item.uid === draggedUid); 342 | 343 | if (!draggedItem) { 344 | debugLog('Could not find dragged item'); 345 | todoItems.forEach((el) => el.classList.remove('drag-over-top', 'drag-over-bottom')); 346 | return; 347 | } 348 | 349 | const rect = itemElement.getBoundingClientRect(); 350 | const midpoint = rect.top + rect.height / 2; 351 | const dropAbove = e.clientY < midpoint; 352 | 353 | let previousUid = null; 354 | 355 | if (dropAbove) { 356 | const targetIndex = items.findIndex((i) => i.uid === item.uid); 357 | if (targetIndex > 0) { 358 | previousUid = items[targetIndex - 1].uid; 359 | } 360 | } else { 361 | previousUid = item.uid; 362 | } 363 | 364 | const draggedIndex = items.findIndex((i) => i.uid === draggedUid); 365 | const targetIndex = items.findIndex((i) => i.uid === item.uid); 366 | 367 | if ( 368 | (dropAbove && targetIndex === draggedIndex + 1) || 369 | (!dropAbove && targetIndex === draggedIndex - 1) 370 | ) { 371 | debugLog('Item already in this position, skipping move'); 372 | todoItems.forEach((el) => el.classList.remove('drag-over-top', 'drag-over-bottom')); 373 | return; 374 | } 375 | 376 | todoItems.forEach((el) => el.classList.remove('drag-over-top', 'drag-over-bottom')); 377 | 378 | debugLog(`Moving item "${draggedItem.summary}" to position after "${previousUid || 'start'}"`); 379 | 380 | const { moveItem } = await import('./TodoOperations.js'); 381 | await moveItem(entityId, draggedUid, previousUid, hass); 382 | }); 383 | } 384 | 385 | /** 386 | * Cleanup drag and drop listeners 387 | * @param {HTMLElement} listContainer - The todo list container 388 | */ 389 | export function cleanupDragAndDrop(listContainer) { 390 | if (!listContainer) return; 391 | 392 | const todoItems = listContainer.querySelectorAll('.todo-item'); 393 | todoItems.forEach((item) => { 394 | item.setAttribute('draggable', 'false'); 395 | item.classList.remove('dragging', 'drag-over-top', 'drag-over-bottom', 'drag-ready'); 396 | }); 397 | } 398 | -------------------------------------------------------------------------------- /src/features/TodoOperations.js: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../utils/Debug.js'; 2 | import { createDueDateElement } from '../ui/DomHelpers.js'; 3 | 4 | /** 5 | * Subscribe to todo items using the exact HA core method 6 | * @param {string} entityId - Entity ID for the todo list 7 | * @param {Object} hass - Home Assistant object 8 | * @returns {Promise} Unsubscribe function 9 | */ 10 | export async function subscribeToTodoItems(entityId, hass) { 11 | if (!hass?.connection) { 12 | debugLog(`No hass connection for ${entityId}`); 13 | return () => {}; 14 | } 15 | 16 | try { 17 | debugLog(`Subscribing to todo items for ${entityId} using HA core method`); 18 | 19 | // Use the exact method from HA core 20 | const unsubscribe = hass.connection.subscribeMessage( 21 | (update) => { 22 | debugLog(`Received todo update for ${entityId}:`, update); 23 | 24 | // This will be handled by the card instance 25 | const event = new CustomEvent('todo-items-updated', { 26 | detail: { entityId, items: update.items || [] }, 27 | bubbles: true, 28 | composed: true 29 | }); 30 | document.dispatchEvent(event); 31 | }, 32 | { 33 | type: 'todo/item/subscribe', 34 | entity_id: entityId 35 | } 36 | ); 37 | 38 | debugLog(`Successfully subscribed to ${entityId}`); 39 | return unsubscribe; 40 | } catch (error) { 41 | debugLog(`Subscription failed for ${entityId}:`, error); 42 | return () => {}; 43 | } 44 | } 45 | 46 | /** 47 | * Fetch todo items using the exact HA core method 48 | * @param {string} entityId - Entity ID for the todo list 49 | * @param {Object} hass - Home Assistant object 50 | * @returns {Promise} Array of todo items 51 | */ 52 | export async function fetchTodoItems(entityId, hass) { 53 | if (!hass) return []; 54 | 55 | try { 56 | debugLog(`Fetching todo items for ${entityId} using HA core method`); 57 | 58 | // Use the exact method from HA core: hass.callWS 59 | const result = await hass.callWS({ 60 | type: 'todo/item/list', 61 | entity_id: entityId 62 | }); 63 | 64 | debugLog(`HA core WS result for ${entityId}:`, result); 65 | return result.items || []; 66 | } catch (error) { 67 | debugLog(`HA core WS call failed for ${entityId}:`, error); 68 | return []; 69 | } 70 | } 71 | 72 | /** 73 | * Add a new todo item 74 | * @param {string} entityId - Entity ID 75 | * @param {string} summary - Item summary 76 | * @param {Object} hass - Home Assistant object 77 | */ 78 | export function addTodoItem(entityId, summary, hass) { 79 | if (!hass || !entityId || !summary) return; 80 | 81 | hass.callService('todo', 'add_item', { 82 | entity_id: entityId, 83 | item: summary 84 | }); 85 | 86 | debugLog(`Adding todo item "${summary}" to ${entityId}`); 87 | } 88 | 89 | /** 90 | * Toggle todo item completion status 91 | * @param {string} entityId - Entity ID 92 | * @param {Object} item - Todo item 93 | * @param {boolean} completed - New completion status 94 | * @param {Object} hass - Home Assistant object 95 | */ 96 | export function toggleTodoItem(entityId, item, completed, hass) { 97 | if (!hass || !entityId || !item) return; 98 | 99 | hass.callService('todo', 'update_item', { 100 | entity_id: entityId, 101 | item: item.uid, 102 | status: completed ? 'completed' : 'needs_action' 103 | }); 104 | 105 | debugLog(`Toggling todo item "${item.summary}" to ${completed ? 'completed' : 'needs_action'}`); 106 | } 107 | 108 | /** 109 | * Update todo item from dialog 110 | * @param {string} entityId - Entity ID 111 | * @param {Object} item - Original todo item 112 | * @param {Object} data - Updated data 113 | * @param {Object} hass - Home Assistant object 114 | */ 115 | export async function updateTodoItemFromDialog(entityId, item, data, hass) { 116 | if (!hass) return; 117 | 118 | const serviceData = { 119 | entity_id: entityId, 120 | item: item.uid, 121 | rename: data.summary 122 | }; 123 | 124 | // Add status if checkbox was present 125 | if (data.completed !== undefined) { 126 | serviceData.status = data.completed ? 'completed' : 'needs_action'; 127 | } 128 | 129 | // Add description if supported 130 | if (data.description !== undefined) { 131 | serviceData.description = data.description || null; 132 | } 133 | 134 | // Add due date/datetime if supported 135 | if (data.dueDate !== undefined) { 136 | if (data.dueDate && data.dueDate.trim() !== '') { 137 | // Check if it's a datetime (contains T) or just a date 138 | if (data.dueDate.includes('T')) { 139 | serviceData.due_datetime = data.dueDate; 140 | } else { 141 | serviceData.due_date = data.dueDate; 142 | } 143 | } else { 144 | // Clear the appropriate field based on what the original item had 145 | if (item.due) { 146 | if (item.due.includes('T')) { 147 | // Original was datetime, clear datetime field 148 | serviceData.due_datetime = null; 149 | } else { 150 | // Original was date only, clear date field 151 | serviceData.due_date = null; 152 | } 153 | } else { 154 | // Fallback: if no original due date, clear date field 155 | serviceData.due_date = null; 156 | } 157 | } 158 | } 159 | 160 | await hass.callService('todo', 'update_item', serviceData); 161 | debugLog(`Updated todo item "${item.summary}" to "${data.summary}"`); 162 | } 163 | 164 | /** 165 | * Add new todo item from dialog 166 | * @param {string} entityId - Entity ID 167 | * @param {Object} data - Item data 168 | * @param {Object} hass - Home Assistant object 169 | */ 170 | export async function addTodoItemFromDialog(entityId, data, hass) { 171 | if (!hass) return; 172 | 173 | const serviceData = { 174 | entity_id: entityId, 175 | item: data.summary 176 | }; 177 | 178 | // Add description if provided 179 | if (data.description) { 180 | serviceData.description = data.description; 181 | } 182 | 183 | // Add due date if provided 184 | if (data.dueDate !== undefined) { 185 | serviceData.due_date = data.dueDate || null; 186 | } 187 | 188 | await hass.callService('todo', 'add_item', serviceData); 189 | debugLog(`Added todo item "${data.summary}"`); 190 | } 191 | 192 | /** 193 | * Delete todo item from dialog 194 | * @param {string} entityId - Entity ID 195 | * @param {Object} item - Todo item to delete 196 | * @param {Object} hass - Home Assistant object 197 | */ 198 | export function deleteTodoItemFromDialog(entityId, item, hass) { 199 | if (!hass) return; 200 | 201 | hass.callService('todo', 'remove_item', { 202 | entity_id: entityId, 203 | item: item.uid 204 | }); 205 | 206 | debugLog(`Deleted todo item "${item.summary}"`); 207 | } 208 | 209 | /** 210 | * Delete completed items from a todo list 211 | * @param {string} entityId - Entity ID for the todo list 212 | * @param {Object} hass - Home Assistant object 213 | */ 214 | export function deleteCompletedItems(entityId, hass) { 215 | if (hass) { 216 | hass.callService('todo', 'remove_completed_items', { 217 | entity_id: entityId 218 | }); 219 | } 220 | } 221 | 222 | /** 223 | * Sort todo items based on display order 224 | * @param {Array} items - Todo items 225 | * @param {string} sortMode - Sort mode 226 | * @param {Object} hass - Home Assistant object for locale 227 | * @returns {Array} Sorted items 228 | */ 229 | export function sortTodoItems(items, sortMode, hass) { 230 | const sortedItems = [...items]; 231 | 232 | // Always separate completed and uncompleted items first 233 | const completedItems = sortedItems.filter((item) => item.status === 'completed'); 234 | const uncompletedItems = sortedItems.filter((item) => item.status !== 'completed'); 235 | 236 | // Sort each group based on the sort mode 237 | let sortedUncompleted = uncompletedItems; 238 | let sortedCompleted = completedItems; 239 | 240 | if (sortMode && sortMode !== 'none') { 241 | const sortFunction = getSortFunction(sortMode, hass); 242 | sortedUncompleted = uncompletedItems.sort(sortFunction); 243 | sortedCompleted = completedItems.sort(sortFunction); 244 | } 245 | 246 | // Return uncompleted items first, then completed items 247 | return [...sortedUncompleted, ...sortedCompleted]; 248 | } 249 | 250 | /** 251 | * Get sort function based on sort mode 252 | * @param {string} sortMode - Sort mode 253 | * @param {Object} hass - Home Assistant object for locale 254 | * @returns {Function} Sort function 255 | */ 256 | function getSortFunction(sortMode, hass) { 257 | switch (sortMode) { 258 | case 'alpha_asc': 259 | return (a, b) => a.summary.localeCompare(b.summary, hass?.locale?.language); 260 | case 'alpha_desc': 261 | return (a, b) => b.summary.localeCompare(a.summary, hass?.locale?.language); 262 | case 'duedate_asc': 263 | return (a, b) => { 264 | const aDue = parseDueDateForSort(a.due); 265 | const bDue = parseDueDateForSort(b.due); 266 | if (!aDue && !bDue) return 0; 267 | if (!aDue) return 1; 268 | if (!bDue) return -1; 269 | return aDue.getTime() - bDue.getTime(); 270 | }; 271 | case 'duedate_desc': 272 | return (a, b) => { 273 | const aDue = parseDueDateForSort(a.due); 274 | const bDue = parseDueDateForSort(b.due); 275 | if (!aDue && !bDue) return 0; 276 | if (!aDue) return 1; 277 | if (!bDue) return -1; 278 | return bDue.getTime() - aDue.getTime(); 279 | }; 280 | default: 281 | return () => 0; 282 | } 283 | } 284 | 285 | /** 286 | * Parse due date string to Date object for sorting 287 | * @param {string} due - Due date string 288 | * @returns {Date|null} Parsed date or null 289 | */ 290 | function parseDueDateForSort(due) { 291 | if (!due) return null; 292 | 293 | try { 294 | if (due.includes('T')) { 295 | return new Date(due); 296 | } else { 297 | // Date only, set to end of day 298 | const date = new Date(`${due}T23:59:59`); 299 | return isNaN(date.getTime()) ? null : date; 300 | } 301 | } catch (e) { 302 | return null; 303 | } 304 | } 305 | 306 | /** 307 | * Create todo item element with improved click handling for swipe gestures 308 | * @param {Object} item - Todo item data 309 | * @param {string} entityId - Entity ID 310 | * @param {Function} toggleCallback - Callback for toggle action 311 | * @param {Function} editCallback - Callback for edit action 312 | * @param {Object} hass - Home Assistant object (needed for ha-relative-time) 313 | * @param {Object} entityState - Entity state object (for feature detection) 314 | * @returns {HTMLElement} Todo item element 315 | */ 316 | export function createTodoItemElement( 317 | item, 318 | entityId, 319 | toggleCallback, 320 | editCallback, 321 | hass, 322 | entityState 323 | ) { 324 | const itemElement = document.createElement('div'); 325 | itemElement.className = `todo-item ${item.status === 'completed' ? 'completed' : ''}`; 326 | itemElement.dataset.itemUid = item.uid; // Store UID for drag and drop 327 | 328 | // Create checkbox 329 | const checkbox = document.createElement('ha-checkbox'); 330 | checkbox.className = 'todo-checkbox'; 331 | checkbox.checked = item.status === 'completed'; 332 | 333 | // Add checkbox change handler 334 | checkbox.addEventListener('change', (e) => { 335 | e.stopPropagation(); // Prevent bubbling to item click 336 | toggleCallback(entityId, item, e.target.checked); 337 | }); 338 | 339 | itemElement.appendChild(checkbox); 340 | 341 | // Create content container 342 | const content = document.createElement('div'); 343 | content.className = 'todo-content'; 344 | 345 | // Create summary 346 | const summary = document.createElement('div'); 347 | summary.className = 'todo-summary'; 348 | summary.textContent = item.summary; 349 | content.appendChild(summary); 350 | 351 | // Add description if present 352 | if (item.description) { 353 | const description = document.createElement('div'); 354 | description.className = 'todo-description'; 355 | description.textContent = item.description; 356 | content.appendChild(description); 357 | } 358 | 359 | // Add due date if present 360 | if (item.due) { 361 | const dueElement = createDueDateElement(item.due); 362 | // Set hass reference for ha-relative-time to work 363 | const relativeTime = dueElement.querySelector('ha-relative-time'); 364 | if (relativeTime && hass) { 365 | relativeTime.hass = hass; 366 | } 367 | content.appendChild(dueElement); 368 | } 369 | 370 | itemElement.appendChild(content); 371 | 372 | // Make entire item draggable if entity supports move feature (no visible handle) 373 | if (entityState && entitySupportsFeature(entityState, 8)) { 374 | itemElement.setAttribute('data-supports-drag', 'true'); 375 | } 376 | 377 | // Simplified and more reliable click handler 378 | let startX = 0; 379 | let startY = 0; 380 | let startTime = 0; 381 | let moved = false; 382 | 383 | const handleStart = (e) => { 384 | // Don't handle clicks on the checkbox or drag handle 385 | if ( 386 | e.target === checkbox || 387 | checkbox.contains(e.target) || 388 | e.target.closest('.todo-drag-handle') 389 | ) { 390 | return; 391 | } 392 | 393 | moved = false; 394 | startTime = Date.now(); 395 | 396 | if (e.type === 'touchstart') { 397 | startX = e.touches[0].clientX; 398 | startY = e.touches[0].clientY; 399 | } else { 400 | startX = e.clientX; 401 | startY = e.clientY; 402 | } 403 | }; 404 | 405 | const handleMove = (e) => { 406 | if (!moved) { 407 | let currentX, currentY; 408 | 409 | if (e.type === 'touchmove') { 410 | currentX = e.touches[0].clientX; 411 | currentY = e.touches[0].clientY; 412 | } else { 413 | currentX = e.clientX; 414 | currentY = e.clientY; 415 | } 416 | 417 | const deltaX = Math.abs(currentX - startX); 418 | const deltaY = Math.abs(currentY - startY); 419 | 420 | // More lenient movement threshold 421 | if (deltaX > 10 || deltaY > 10) { 422 | moved = true; 423 | } 424 | } 425 | }; 426 | 427 | const handleEnd = (e) => { 428 | // Don't handle clicks on the checkbox or drag handle 429 | if ( 430 | e.target === checkbox || 431 | checkbox.contains(e.target) || 432 | e.target.closest('.todo-drag-handle') 433 | ) { 434 | return; 435 | } 436 | 437 | const duration = Date.now() - startTime; 438 | 439 | // More lenient conditions for click detection 440 | if (!moved && duration < 1000) { 441 | // Small delay to ensure we don't interfere with swipe gestures 442 | setTimeout(() => { 443 | editCallback(entityId, item); 444 | }, 10); 445 | } 446 | }; 447 | 448 | // Add event listeners 449 | itemElement.addEventListener('touchstart', handleStart, { passive: true }); 450 | itemElement.addEventListener('touchmove', handleMove, { passive: true }); 451 | itemElement.addEventListener('touchend', handleEnd, { passive: true }); 452 | itemElement.addEventListener('mousedown', handleStart); 453 | itemElement.addEventListener('mousemove', handleMove); 454 | itemElement.addEventListener('mouseup', handleEnd); 455 | 456 | // Fallback click handler for better reliability 457 | itemElement.addEventListener('click', (e) => { 458 | // Don't handle clicks on the checkbox or drag handle 459 | if ( 460 | e.target === checkbox || 461 | checkbox.contains(e.target) || 462 | e.target.closest('.todo-drag-handle') 463 | ) { 464 | return; 465 | } 466 | 467 | // Only handle if the other handlers didn't fire recently 468 | if (!moved && Date.now() - startTime < 100) { 469 | e.preventDefault(); 470 | e.stopPropagation(); 471 | editCallback(entityId, item); 472 | } 473 | }); 474 | 475 | return itemElement; 476 | } 477 | 478 | /** 479 | * Check if entity supports a specific feature 480 | * @param {Object} entityState - Entity state object 481 | * @param {number} feature - Feature flag 482 | * @returns {boolean} True if feature is supported 483 | */ 484 | export function entitySupportsFeature(entityState, feature) { 485 | const supportedFeatures = entityState.attributes?.supported_features || 0; 486 | return (supportedFeatures & feature) !== 0; 487 | } 488 | 489 | /** 490 | * Move a todo item to a new position 491 | * @param {string} entityId - Entity ID 492 | * @param {string} itemUid - UID of item to move 493 | * @param {string|null} previousUid - UID of item that should come before it (null for first position) 494 | * @param {Object} hass - Home Assistant object 495 | * @returns {Promise} 496 | */ 497 | export async function moveItem(entityId, itemUid, previousUid, hass) { 498 | if (!hass) { 499 | debugLog('No hass object available for moveItem'); 500 | return; 501 | } 502 | 503 | try { 504 | debugLog(`Moving item ${itemUid} after ${previousUid || 'start'} in ${entityId}`); 505 | 506 | await hass.callWS({ 507 | type: 'todo/item/move', 508 | entity_id: entityId, 509 | uid: itemUid, 510 | previous_uid: previousUid || undefined 511 | }); 512 | 513 | debugLog(`Successfully moved item ${itemUid}`); 514 | } catch (error) { 515 | console.error('Error moving todo item:', error); 516 | debugLog(`Failed to move item: ${error.message}`); 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /src/core/CardBuilder.js: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../utils/Debug.js'; 2 | import { createCardWithTitle, createIconElement } from '../ui/DomHelpers.js'; 3 | import { 4 | setupSearchForCard, 5 | matchesSearch, 6 | handleSearchKeydown 7 | } from '../features/SearchFunctionality.js'; 8 | import { 9 | subscribeToTodoItems, 10 | fetchTodoItems, 11 | sortTodoItems, 12 | createTodoItemElement, 13 | entitySupportsFeature 14 | } from '../features/TodoOperations.js'; 15 | 16 | /** 17 | * CardBuilder handles all card building and rendering functionality for TodoSwipeCard 18 | * Manages card creation, updates, and rendering logic 19 | */ 20 | export class CardBuilder { 21 | constructor(cardInstance) { 22 | this.cardInstance = cardInstance; 23 | } 24 | 25 | /** 26 | * Get hass object from card instance 27 | * @returns {Object} Home Assistant object 28 | * @private 29 | */ 30 | get _hass() { 31 | return this.cardInstance._hass; 32 | } 33 | 34 | /** 35 | * Get config object from card instance 36 | * @returns {Object} Card configuration 37 | * @private 38 | */ 39 | get _config() { 40 | return this.cardInstance._config; 41 | } 42 | 43 | /** 44 | * Get entity ID from entity configuration (handles both string and object formats) 45 | * @param {string|Object} entity - Entity configuration 46 | * @returns {string} Entity ID 47 | * @private 48 | */ 49 | _getEntityId(entity) { 50 | if (typeof entity === 'string') { 51 | return entity; 52 | } 53 | return entity?.entity || ''; 54 | } 55 | 56 | /** 57 | * Get entity configuration by ID 58 | * @param {string} entityId - Entity ID to find 59 | * @returns {Object|null} Entity configuration object or null if not found 60 | * @private 61 | */ 62 | _getEntityConfig(entityId) { 63 | if (!this._config?.entities) return null; 64 | 65 | const entity = this._config.entities.find((entity) => this._getEntityId(entity) === entityId); 66 | 67 | if (typeof entity === 'string') { 68 | return { entity: entityId }; 69 | } 70 | 71 | return entity || null; 72 | } 73 | 74 | /** 75 | * Create native todo cards from entities 76 | */ 77 | async createNativeTodoCards() { 78 | // Ensure sliderElement exists before proceeding 79 | if (!this.cardInstance.sliderElement) { 80 | debugLog('sliderElement is null at start of createNativeTodoCards'); 81 | return; 82 | } 83 | 84 | // Check for build cancellation 85 | if (this.cardInstance._buildCanceled) { 86 | debugLog('Card creation canceled before starting'); 87 | return; 88 | } 89 | 90 | // Store reference to slider element to check for changes 91 | const initialSlider = this.cardInstance.sliderElement; 92 | 93 | // Process entities sequentially for better performance 94 | for (let i = 0; i < this._config.entities.length; i++) { 95 | // Check for cancellation at each iteration 96 | if (this.cardInstance._buildCanceled) { 97 | debugLog('Card creation canceled during processing'); 98 | return; 99 | } 100 | 101 | const entityConfig = this._config.entities[i]; 102 | const entityId = this._getEntityId(entityConfig); 103 | 104 | if (!entityId || entityId.trim() === '') { 105 | continue; 106 | } 107 | 108 | // Check if slider element is still the same (hasn't been rebuilt) 109 | if (this.cardInstance.sliderElement !== initialSlider) { 110 | debugLog('sliderElement changed during card creation - build was interrupted'); 111 | return; 112 | } 113 | 114 | // Check if slider element still exists 115 | if (!this.cardInstance.sliderElement) { 116 | debugLog('sliderElement became null during card creation'); 117 | return; 118 | } 119 | 120 | const slideDiv = document.createElement('div'); 121 | slideDiv.className = 'slide'; 122 | 123 | try { 124 | // Create native todo card element 125 | const cardElement = await this.createNativeTodoCard(entityConfig); 126 | 127 | // Check for cancellation after async operation 128 | if (this.cardInstance._buildCanceled) { 129 | debugLog('Card creation canceled after creating card element'); 130 | return; 131 | } 132 | 133 | // Store reference to the card 134 | this.cardInstance.cards[i] = { 135 | element: cardElement, 136 | slide: slideDiv, 137 | entityId: entityId, 138 | entityConfig: entityConfig 139 | }; 140 | 141 | // Add card to slide 142 | slideDiv.appendChild(cardElement); 143 | 144 | // Add custom delete button if configured 145 | if (this._config.show_completed && this._config.show_completed_menu) { 146 | const deleteButton = this.cardInstance._createDeleteButton(entityId, entityConfig); 147 | slideDiv.appendChild(deleteButton); 148 | } 149 | 150 | // Add icon if configured 151 | if (this._config.show_icons) { 152 | const iconElement = createIconElement(entityConfig, entityId, this._hass); 153 | slideDiv.appendChild(iconElement); 154 | } 155 | 156 | // Final check before appending - ensure slider still exists and is the same 157 | if ( 158 | this.cardInstance.sliderElement && 159 | this.cardInstance.sliderElement === initialSlider && 160 | !this.cardInstance._buildCanceled 161 | ) { 162 | this.cardInstance.sliderElement.appendChild(slideDiv); 163 | debugLog(`Created native todo card for entity: ${entityId}`); 164 | } else { 165 | debugLog('sliderElement changed, became null, or build canceled before appending slide'); 166 | return; 167 | } 168 | } catch (e) { 169 | if (!this.cardInstance._buildCanceled) { 170 | console.error(`Error creating native todo card ${i}:`, entityId, e); 171 | const errorDiv = document.createElement('div'); 172 | errorDiv.style.cssText = 173 | 'color: red; background: white; padding: 16px; border: 1px solid red; height: 100%; box-sizing: border-box;'; 174 | errorDiv.textContent = `Error creating card: ${e.message || e}. Check console for details.`; 175 | slideDiv.appendChild(errorDiv); 176 | 177 | // Check if sliderElement exists before appending error 178 | if ( 179 | this.cardInstance.sliderElement && 180 | this.cardInstance.sliderElement === initialSlider 181 | ) { 182 | this.cardInstance.sliderElement.appendChild(slideDiv); 183 | } 184 | this.cardInstance.cards[i] = { error: true, slide: slideDiv }; 185 | } 186 | } 187 | } 188 | 189 | // Filter out any potential gaps if errors occurred 190 | this.cardInstance.cards = this.cardInstance.cards.filter(Boolean); 191 | debugLog(`Card creation completed. Created ${this.cardInstance.cards.length} cards`); 192 | } 193 | 194 | /** 195 | * Create native todo card 196 | * @param {Object} entityConfig - Entity configuration 197 | * @returns {Promise} Card element 198 | */ 199 | async createNativeTodoCard(entityConfig) { 200 | const entityId = this._getEntityId(entityConfig); 201 | debugLog('Creating native todo card for entity:', entityId); 202 | 203 | // Initialize cache if needed 204 | if (!this.cardInstance._todoItemsCache) { 205 | this.cardInstance._todoItemsCache = new Map(); 206 | } 207 | if (!this.cardInstance._todoSubscriptions) { 208 | this.cardInstance._todoSubscriptions = new Map(); 209 | } 210 | 211 | // Create the main card element 212 | const cardElement = document.createElement('div'); 213 | cardElement.className = 'native-todo-card'; 214 | 215 | // Apply background image if configured 216 | if (typeof entityConfig === 'object' && entityConfig.background_image) { 217 | cardElement.style.backgroundImage = `url('${entityConfig.background_image}')`; 218 | cardElement.style.backgroundPosition = 'center center'; 219 | cardElement.style.backgroundRepeat = 'no-repeat'; 220 | cardElement.style.backgroundSize = 'cover'; 221 | } 222 | 223 | let finalElement = cardElement; 224 | 225 | // Handle title wrapper 226 | const showTitle = (typeof entityConfig === 'object' && entityConfig.show_title) || false; 227 | const titleText = (typeof entityConfig === 'object' && entityConfig.title) || ''; 228 | if (showTitle && titleText) { 229 | finalElement = createCardWithTitle(cardElement, titleText); 230 | } 231 | 232 | // Create add row if enabled 233 | if (this._config.show_create) { 234 | const addRow = this.createAddRow(entityId); 235 | cardElement.appendChild(addRow); 236 | } 237 | 238 | // Create todo list container 239 | const listContainer = document.createElement('div'); 240 | listContainer.className = 'todo-list'; 241 | cardElement.appendChild(listContainer); 242 | 243 | // Set up search functionality if enabled 244 | if (this._config.enable_search && this._config.show_create) { 245 | setupSearchForCard(cardElement, entityId, this.cardInstance); 246 | } 247 | 248 | // Set up subscription if hass is available 249 | if (this._hass) { 250 | debugLog('Setting up todo subscription for', entityId); 251 | 252 | // Clean up any existing subscription 253 | const existingUnsub = this.cardInstance._todoSubscriptions.get(entityId); 254 | if (existingUnsub && typeof existingUnsub === 'function') { 255 | try { 256 | existingUnsub(); 257 | } catch (e) { 258 | debugLog('Error cleaning up subscription:', e); 259 | } 260 | } 261 | 262 | // Create new subscription 263 | const unsubscribe = await subscribeToTodoItems(entityId, this._hass); 264 | this.cardInstance._todoSubscriptions.set(entityId, unsubscribe); 265 | 266 | // Do initial fetch 267 | debugLog('Doing initial fetch for', entityId); 268 | setTimeout(async () => { 269 | await this.updateNativeTodoCard(finalElement, entityId); 270 | }, 100); 271 | } 272 | 273 | return finalElement; 274 | } 275 | 276 | /** 277 | * Create add item row 278 | * @param {string} entityId - Entity ID 279 | * @returns {HTMLElement} Add row element 280 | */ 281 | createAddRow(entityId) { 282 | const addRow = document.createElement('div'); 283 | addRow.className = 'add-row'; 284 | 285 | // Create text input 286 | const textField = document.createElement('div'); 287 | textField.className = 'add-textfield'; 288 | 289 | const input = document.createElement('input'); 290 | input.type = 'text'; 291 | input.placeholder = this._config.enable_search ? 'Type to search / add' : 'Add item'; 292 | 293 | // Add keydown handler for Enter key 294 | input.addEventListener('keydown', (e) => { 295 | if (e.key === 'Enter') { 296 | const value = input.value.trim(); 297 | if (value) { 298 | // Check if this is search mode 299 | if (this._config.enable_search) { 300 | // Use the search functionality 301 | handleSearchKeydown( 302 | e, 303 | entityId, 304 | input.closest('.native-todo-card') || input.closest('.todo-card-with-title-wrapper'), 305 | input, 306 | this.cardInstance 307 | ); 308 | } else { 309 | // Non-search mode - just add the item 310 | this.cardInstance._addTodoItem(entityId, value); 311 | input.value = ''; 312 | input.focus(); 313 | } 314 | } 315 | } else if (e.key === 'Escape' && this._config.enable_search) { 316 | // Clear search on Escape 317 | input.value = ''; 318 | this.cardInstance._searchStates.delete(entityId); 319 | this.cardInstance._currentSearchText = ''; 320 | // Find the card element and update it 321 | const cardElement = 322 | input.closest('.native-todo-card') || input.closest('.todo-card-with-title-wrapper'); 323 | if (cardElement) { 324 | this.updateNativeTodoCard(cardElement, entityId); 325 | } 326 | } 327 | }); 328 | 329 | textField.appendChild(input); 330 | addRow.appendChild(textField); 331 | 332 | // Create add button if enabled 333 | if (this._config.show_addbutton) { 334 | const addButton = document.createElement('button'); 335 | addButton.className = 'add-button'; 336 | addButton.title = 'Add item'; 337 | addButton.innerHTML = ` 338 | 339 | 340 | 341 | `; 342 | 343 | addButton.addEventListener('click', () => { 344 | const value = input.value.trim(); 345 | if (value) { 346 | // Check if this is search mode - same logic as Enter key 347 | if (this._config.enable_search) { 348 | // CLEAR SEARCH STATE FIRST - before doing anything else 349 | debugLog(`Clearing search state for ${entityId} BEFORE adding item (+ button)`); 350 | this.cardInstance._searchStates.delete(entityId); 351 | this.cardInstance._currentSearchText = ''; 352 | input.value = ''; 353 | 354 | // Check if the search text matches any existing items 355 | const entityState = this.cardInstance._hass?.states?.[entityId]; 356 | const items = entityState?.attributes?.items || []; 357 | const exactMatch = items.some( 358 | (item) => item.summary.toLowerCase() === value.toLowerCase() 359 | ); 360 | 361 | if (!exactMatch) { 362 | debugLog(`No exact match found, adding item: "${value}" (+ button)`); 363 | // Add new item since no exact match found 364 | this.cardInstance._addTodoItem(entityId, value); 365 | } else { 366 | debugLog(`Exact match found, not adding item: "${value}" (+ button)`); 367 | } 368 | 369 | // Update the card to clear search display 370 | const cardElement = 371 | input.closest('.native-todo-card') || input.closest('.todo-card-with-title-wrapper'); 372 | if (cardElement) { 373 | this.updateNativeTodoCard(cardElement, entityId); 374 | } 375 | } else { 376 | // Non-search mode - just add the item (original behavior) 377 | this.cardInstance._addTodoItem(entityId, value); 378 | input.value = ''; 379 | } 380 | input.focus(); 381 | } 382 | }); 383 | 384 | addRow.appendChild(addButton); 385 | } 386 | 387 | return addRow; 388 | } 389 | 390 | /** 391 | * Update native todo card 392 | * @param {HTMLElement} cardElement - Card element 393 | * @param {string} entityId - Entity ID 394 | */ 395 | async updateNativeTodoCard(cardElement, entityId) { 396 | debugLog(`Starting updateNativeTodoCard for ${entityId}`); 397 | 398 | if (!this._hass || !entityId) { 399 | debugLog('No hass or entityId provided'); 400 | return; 401 | } 402 | 403 | const entityState = this._hass.states[entityId]; 404 | if (!entityState) { 405 | debugLog(`Entity ${entityId} not found in hass.states`); 406 | return; 407 | } 408 | 409 | // Always fetch fresh items instead of relying on cache 410 | let items = []; 411 | try { 412 | items = await fetchTodoItems(entityId, this._hass); 413 | debugLog(`Fetched ${items.length} fresh items for ${entityId}`); 414 | 415 | // Update cache with fresh items 416 | if (!this.cardInstance._todoItemsCache) { 417 | this.cardInstance._todoItemsCache = new Map(); 418 | } 419 | this.cardInstance._todoItemsCache.set(entityId, items); 420 | } catch (error) { 421 | debugLog(`Failed to fetch items for ${entityId}, trying cache:`, error); 422 | 423 | // Fallback to cache if fetch fails 424 | if (this.cardInstance._todoItemsCache && this.cardInstance._todoItemsCache.has(entityId)) { 425 | items = this.cardInstance._todoItemsCache.get(entityId) || []; 426 | debugLog(`Using ${items.length} cached items for ${entityId}`); 427 | } else { 428 | // Last resort: try to get from entity attributes 429 | items = entityState.attributes?.items || []; 430 | debugLog(`Using ${items.length} items from entity attributes for ${entityId}`); 431 | } 432 | } 433 | 434 | // Find the list container 435 | let listContainer = null; 436 | if (cardElement.classList.contains('todo-card-with-title-wrapper')) { 437 | listContainer = cardElement.querySelector('.native-todo-card .todo-list'); 438 | } else if (cardElement.classList.contains('native-todo-card')) { 439 | listContainer = cardElement.querySelector('.todo-list'); 440 | } else { 441 | listContainer = cardElement.querySelector('.todo-list'); 442 | } 443 | 444 | if (!listContainer) { 445 | debugLog('Creating missing list container'); 446 | let targetCard = cardElement; 447 | if (cardElement.classList.contains('todo-card-with-title-wrapper')) { 448 | targetCard = cardElement.querySelector('.native-todo-card'); 449 | } 450 | 451 | if (targetCard) { 452 | listContainer = document.createElement('div'); 453 | listContainer.className = 'todo-list'; 454 | targetCard.appendChild(listContainer); 455 | } else { 456 | debugLog('Could not create list container'); 457 | return; 458 | } 459 | } 460 | 461 | // Apply sorting (always use ALL items for search purposes) 462 | const entityConfig = this._getEntityConfig(entityId); 463 | const allSortedItems = sortTodoItems(items, entityConfig?.display_order, this._hass); 464 | const searchText = this.cardInstance._searchStates.get(entityId) || ''; 465 | const isSearchActive = searchText && searchText.trim() !== ''; 466 | 467 | debugLog(`Search text for filtering: "${searchText}"`); 468 | 469 | // Filter by search text (if searching, include ALL matching items regardless of completion status) 470 | let filteredItems = isSearchActive 471 | ? allSortedItems.filter((item) => matchesSearch(item, searchText)) 472 | : allSortedItems; 473 | 474 | // Apply hide_future_items filter (only when not searching) 475 | if (!isSearchActive && entityConfig?.hide_future_items) { 476 | const now = new Date(); 477 | now.setHours(23, 59, 59, 999); // End of today 478 | 479 | filteredItems = filteredItems.filter((item) => { 480 | // Always show completed items and items without due dates 481 | if (item.status === 'completed' || !item.due) { 482 | return true; 483 | } 484 | 485 | // Check if due date is today or in the past 486 | try { 487 | const dueDate = new Date(item.due); 488 | return dueDate <= now; 489 | } catch (e) { 490 | return true; // Show if date parsing fails 491 | } 492 | }); 493 | 494 | debugLog(`After hide_future_items filter: ${filteredItems.length} items`); 495 | } 496 | 497 | // Apply max_items limit (only to incomplete items, only when not searching) 498 | if (!isSearchActive && entityConfig?.max_items && typeof entityConfig.max_items === 'number') { 499 | const incompleteItems = filteredItems.filter((item) => item.status !== 'completed'); 500 | const completedItems = filteredItems.filter((item) => item.status === 'completed'); 501 | 502 | // Limit incomplete items, keep all completed items 503 | const limitedIncompleteItems = incompleteItems.slice(0, entityConfig.max_items); 504 | 505 | filteredItems = [...limitedIncompleteItems, ...completedItems]; 506 | 507 | debugLog( 508 | `After max_items filter (limit: ${entityConfig.max_items}): ${filteredItems.length} items (${limitedIncompleteItems.length} incomplete + ${completedItems.length} completed)` 509 | ); 510 | } 511 | 512 | debugLog( 513 | `Rendering ${filteredItems.length} items for ${entityId} (from ${items.length} total)` 514 | ); 515 | 516 | // Clear and render 517 | listContainer.innerHTML = ''; 518 | 519 | if (filteredItems.length > 0) { 520 | filteredItems.forEach((item, index) => { 521 | try { 522 | const itemElement = createTodoItemElement( 523 | item, 524 | entityId, 525 | this.cardInstance._toggleTodoItem, 526 | this.cardInstance._editTodoItem, 527 | this._hass, 528 | entityState 529 | ); 530 | 531 | // Show completed items when actively searching, otherwise respect show_completed setting 532 | if (!this._config.show_completed && item.status === 'completed' && !isSearchActive) { 533 | itemElement.style.display = 'none'; 534 | } 535 | 536 | listContainer.appendChild(itemElement); 537 | } catch (e) { 538 | console.error(`Error creating item element ${index}:`, e, item); 539 | } 540 | }); 541 | } 542 | 543 | // Update search results counter 544 | this.updateSearchCounter( 545 | cardElement, 546 | entityId, 547 | searchText, 548 | filteredItems.length, 549 | allSortedItems.length 550 | ); 551 | 552 | debugLog(`Finished updating card for ${entityId}`); 553 | 554 | // Setup drag and drop if entity supports it 555 | if (entitySupportsFeature(entityState, 8)) { 556 | // Import drag and drop functionality 557 | const { setupDragAndDrop } = await import('../features/DragDrop.js'); 558 | setupDragAndDrop(listContainer, entityId, filteredItems, this._hass); 559 | debugLog(`Drag and drop enabled for ${entityId}`); 560 | } 561 | } 562 | 563 | /** 564 | * Update search results counter 565 | * @param {HTMLElement} cardElement - Card element 566 | * @param {string} entityId - Entity ID 567 | * @param {string} searchText - Current search text 568 | * @param {number} filteredCount - Number of filtered items 569 | * @param {number} totalCount - Total number of items 570 | */ 571 | updateSearchCounter(cardElement, entityId, searchText, filteredCount, totalCount) { 572 | // Find the add row 573 | let addRow = null; 574 | if (cardElement.classList.contains('todo-card-with-title-wrapper')) { 575 | addRow = cardElement.querySelector('.native-todo-card .add-row'); 576 | } else { 577 | addRow = cardElement.querySelector('.add-row'); 578 | } 579 | 580 | if (!addRow) return; 581 | 582 | // Remove existing counter 583 | const existingCounter = cardElement.querySelector('.search-counter'); 584 | if (existingCounter) { 585 | existingCounter.remove(); 586 | } 587 | 588 | // Handle add-row CSS class and counter 589 | if (searchText && searchText.trim() !== '' && totalCount > 0) { 590 | // Add CSS class to reduce add-row margin when search counter is present 591 | addRow.classList.add('has-search-counter'); 592 | 593 | // Create and insert counter 594 | const counter = document.createElement('div'); 595 | counter.className = 'search-counter'; 596 | counter.textContent = `Showing ${filteredCount} of ${totalCount} results`; 597 | 598 | // Insert after the add-row 599 | addRow.parentNode.insertBefore(counter, addRow.nextSibling); 600 | } else { 601 | // Remove CSS class to restore default spacing when no search counter 602 | addRow.classList.remove('has-search-counter'); 603 | } 604 | } 605 | } 606 | -------------------------------------------------------------------------------- /src/core/DialogManager.js: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../utils/Debug.js'; 2 | import { 3 | updateTodoItemFromDialog, 4 | addTodoItemFromDialog, 5 | deleteTodoItemFromDialog, 6 | entitySupportsFeature 7 | } from '../features/TodoOperations.js'; 8 | 9 | /** 10 | * DialogManager handles all dialog-related functionality for TodoSwipeCard 11 | * Manages todo item edit, add, and delete dialogs 12 | */ 13 | export class DialogManager { 14 | constructor(cardInstance) { 15 | this.cardInstance = cardInstance; 16 | this.currentDialog = null; // Track current open dialog 17 | this.dialogOpenTime = 0; // Prevent rapid dialog creation 18 | } 19 | 20 | /** 21 | * Get hass object from card instance 22 | * @returns {Object} Home Assistant object 23 | * @private 24 | */ 25 | get _hass() { 26 | return this.cardInstance._hass; 27 | } 28 | 29 | /** 30 | * Get config object from card instance 31 | * @returns {Object} Card configuration 32 | * @private 33 | */ 34 | get _config() { 35 | return this.cardInstance._config; 36 | } 37 | 38 | /** 39 | * Create a custom delete button that looks like ha-button but is fully styleable 40 | * @param {string} text - Button text 41 | * @param {string} slot - Button slot 42 | * @returns {HTMLElement} Custom delete button 43 | * @private 44 | */ 45 | _createCustomDeleteButton(text, slot) { 46 | const button = document.createElement('button'); 47 | button.slot = slot; 48 | button.textContent = text; 49 | button.setAttribute('destructive', ''); 50 | 51 | // Style it to look like ha-button but with red color 52 | button.style.cssText = ` 53 | background-color: #f44336; 54 | color: white; 55 | border: none; 56 | border-radius: 4px; 57 | padding: 8px 16px; 58 | font-family: var(--mdc-typography-button-font-family, inherit); 59 | font-size: var(--mdc-typography-button-font-size, 0.875rem); 60 | font-weight: var(--mdc-typography-button-font-weight, 500); 61 | text-transform: uppercase; 62 | letter-spacing: 0.0892857143em; 63 | cursor: pointer; 64 | min-width: 64px; 65 | height: 36px; 66 | display: inline-flex; 67 | align-items: center; 68 | justify-content: center; 69 | box-sizing: border-box; 70 | transition: background-color 0.2s, box-shadow 0.2s; 71 | outline: none; 72 | `; 73 | 74 | // Add hover and focus effects 75 | button.addEventListener('mouseenter', () => { 76 | button.style.backgroundColor = '#d32f2f'; 77 | button.style.boxShadow = '0px 2px 4px rgba(244, 67, 54, 0.3)'; 78 | }); 79 | 80 | button.addEventListener('mouseleave', () => { 81 | button.style.backgroundColor = '#f44336'; 82 | button.style.boxShadow = 'none'; 83 | }); 84 | 85 | button.addEventListener('focus', () => { 86 | button.style.backgroundColor = '#d32f2f'; 87 | button.style.boxShadow = '0px 0px 0px 2px rgba(244, 67, 54, 0.3)'; 88 | }); 89 | 90 | button.addEventListener('blur', () => { 91 | button.style.backgroundColor = '#f44336'; 92 | button.style.boxShadow = 'none'; 93 | }); 94 | 95 | return button; 96 | } 97 | 98 | /** 99 | * Create a regular button that matches ha-button styling 100 | * @param {string} text - Button text 101 | * @param {string} slot - Button slot 102 | * @returns {HTMLElement} Regular button 103 | * @private 104 | */ 105 | _createRegularButton(text, slot) { 106 | const button = document.createElement('ha-button'); 107 | button.slot = slot; 108 | button.textContent = text; 109 | return button; 110 | } 111 | 112 | /** 113 | * Edit todo item using a custom HA-style dialog 114 | * @param {string} entityId - Entity ID 115 | * @param {Object} item - Todo item 116 | */ 117 | editTodoItem(entityId, item) { 118 | // Prevent rapid dialog creation (debounce) 119 | const now = Date.now(); 120 | if (now - this.dialogOpenTime < 300) { 121 | debugLog('Preventing rapid dialog creation'); 122 | return; 123 | } 124 | this.dialogOpenTime = now; 125 | 126 | debugLog(`Edit todo item "${item.summary}" in ${entityId}`); 127 | this.showTodoItemEditDialog(entityId, item); 128 | } 129 | 130 | /** 131 | * Show todo item edit dialog with full HA native features 132 | * @param {string} entityId - Entity ID 133 | * @param {Object} item - Todo item (undefined for new items) 134 | */ 135 | showTodoItemEditDialog(entityId, item = undefined) { 136 | // Close any existing dialog first 137 | this.closeCurrentDialog(); 138 | 139 | debugLog(`Opening todo edit dialog for ${item ? 'existing' : 'new'} item`); 140 | 141 | // Create dialog with HA's native styling and accessibility 142 | const dialog = document.createElement('ha-dialog'); 143 | dialog.heading = item ? 'Edit item' : 'Add Todo Item'; 144 | dialog.open = true; 145 | dialog.style.setProperty('--mdc-dialog-min-width', 'min(600px, 95vw)'); 146 | dialog.style.setProperty('--mdc-dialog-max-width', 'min(600px, 95vw)'); 147 | 148 | // Add accessibility attributes 149 | dialog.setAttribute('role', 'dialog'); 150 | dialog.setAttribute('aria-labelledby', 'dialog-title'); 151 | dialog.setAttribute('aria-modal', 'true'); 152 | 153 | // Track this dialog 154 | this.currentDialog = dialog; 155 | 156 | // Create content container 157 | const content = document.createElement('div'); 158 | content.style.cssText = ` 159 | padding: 8px 0; 160 | display: flex; 161 | flex-direction: column; 162 | gap: 16px; 163 | `; 164 | 165 | // Get entity state and features 166 | const entityState = this._hass?.states?.[entityId]; 167 | const supportsDescription = entityState && entitySupportsFeature(entityState, 64); 168 | const supportsDueDate = 169 | entityState && 170 | (entitySupportsFeature(entityState, 16) || entitySupportsFeature(entityState, 32)); 171 | const supportsDelete = entityState && entitySupportsFeature(entityState, 2); 172 | 173 | // Debug the feature support 174 | debugLog(`Entity features for ${entityId}:`, { 175 | supportedFeatures: entityState?.attributes?.supported_features, 176 | supportsDescription: supportsDescription, 177 | supportsDueDate: supportsDueDate, 178 | supportsDelete: supportsDelete 179 | }); 180 | 181 | // Create summary/checkbox row 182 | const summaryRow = document.createElement('div'); 183 | summaryRow.style.cssText = 'display: flex; align-items: flex-start; gap: 8px;'; 184 | 185 | // Checkbox for completion status (only for existing items) 186 | let checkbox = null; 187 | if (item) { 188 | checkbox = document.createElement('ha-checkbox'); 189 | checkbox.checked = item.status === 'completed'; 190 | checkbox.style.marginTop = '8px'; 191 | summaryRow.appendChild(checkbox); 192 | } 193 | 194 | // Summary input with validation 195 | const summaryField = document.createElement('ha-textfield'); 196 | summaryField.label = 'Task name'; 197 | summaryField.value = item?.summary || ''; 198 | summaryField.required = true; 199 | summaryField.style.flexGrow = '1'; 200 | summaryField.dialogInitialFocus = true; 201 | summaryField.validationMessage = 'Task name is required'; 202 | summaryRow.appendChild(summaryField); 203 | 204 | content.appendChild(summaryRow); 205 | 206 | // Description field 207 | let descriptionField = null; 208 | const showDescription = true; // Always show for now 209 | if (showDescription) { 210 | descriptionField = document.createElement('ha-textfield'); 211 | descriptionField.label = 'Description'; 212 | descriptionField.value = item?.description || ''; 213 | descriptionField.setAttribute('type', 'textarea'); 214 | descriptionField.setAttribute('rows', '3'); 215 | descriptionField.style.cssText = ` 216 | width: 100%; 217 | display: block; 218 | margin-bottom: 16px; 219 | `; 220 | content.appendChild(descriptionField); 221 | debugLog('Description field (ha-textfield textarea) added to dialog'); 222 | } 223 | 224 | // Due date field (if supported) 225 | let dateField = null; 226 | let timeField = null; 227 | if (supportsDueDate) { 228 | const dueSection = document.createElement('div'); 229 | 230 | // Create the "Due date:" label 231 | const dueLabel = document.createElement('span'); 232 | dueLabel.className = 'label'; 233 | dueLabel.textContent = 'Due date:'; 234 | dueLabel.style.cssText = ` 235 | font-size: var(--ha-font-size-s, 12px); 236 | font-weight: var(--ha-font-weight-medium, 500); 237 | color: var(--input-label-ink-color, var(--primary-text-color)); 238 | display: block; 239 | margin-bottom: 8px; 240 | `; 241 | dueSection.appendChild(dueLabel); 242 | 243 | // Create flex container for date and time inputs 244 | const flexContainer = document.createElement('div'); 245 | flexContainer.className = 'flex'; 246 | flexContainer.style.cssText = ` 247 | display: flex; 248 | justify-content: space-between; 249 | gap: 16px; 250 | `; 251 | 252 | // Parse existing due date 253 | let dueDate = ''; 254 | let dueTime = ''; 255 | if (item?.due) { 256 | try { 257 | const due = new Date(item.due); 258 | if (!isNaN(due.getTime())) { 259 | dueDate = due.toISOString().split('T')[0]; // YYYY-MM-DD format 260 | if (item.due.includes('T')) { 261 | dueTime = due.toTimeString().split(' ')[0].substring(0, 5); // HH:MM format 262 | } 263 | } 264 | } catch (e) { 265 | debugLog('Error parsing due date:', e); 266 | } 267 | } 268 | 269 | // Create date input 270 | const dateContainer = document.createElement('div'); 271 | dateContainer.style.cssText = `flex-grow: 1; position: relative;`; 272 | 273 | dateField = document.createElement('input'); 274 | dateField.type = 'date'; 275 | dateField.value = dueDate; 276 | dateField.style.cssText = ` 277 | width: 100%; 278 | height: 56px; 279 | padding: 20px 12px 6px 12px; 280 | border: none; 281 | border-bottom: 1px solid var(--divider-color); 282 | border-radius: 0; 283 | background: transparent; 284 | color: var(--primary-text-color); 285 | font-family: var(--mdc-typography-subtitle1-font-family, inherit); 286 | font-size: var(--mdc-typography-subtitle1-font-size, 1rem); 287 | line-height: var(--mdc-typography-subtitle1-line-height, 1.75rem); 288 | box-sizing: border-box; 289 | outline: none; 290 | transition: border-bottom-color 0.15s ease; 291 | cursor: pointer; 292 | -webkit-appearance: none; 293 | -moz-appearance: textfield; 294 | `; 295 | 296 | // Create wrapper 297 | const dateWrapper = document.createElement('div'); 298 | dateWrapper.style.cssText = ` 299 | position: relative; 300 | background: var(--mdc-text-field-fill-color, #f5f5f5); 301 | border-radius: 4px 4px 0 0; 302 | min-height: 56px; 303 | display: flex; 304 | align-items: center; 305 | `; 306 | 307 | // Create floating label for date field 308 | const dateLabel = document.createElement('span'); 309 | dateLabel.textContent = 'Due Date'; 310 | dateLabel.style.cssText = ` 311 | position: absolute; 312 | left: 12px; 313 | top: 8px; 314 | font-size: 12px; 315 | color: var(--secondary-text-color); 316 | pointer-events: none; 317 | transition: all 0.2s ease; 318 | `; 319 | 320 | // Add clear functionality 321 | const clearButton = document.createElement('button'); 322 | clearButton.type = 'button'; 323 | clearButton.innerHTML = '×'; 324 | clearButton.style.cssText = ` 325 | position: absolute; 326 | right: 36px; 327 | top: 50%; 328 | transform: translateY(-50%); 329 | background: none; 330 | border: none; 331 | color: var(--secondary-text-color); 332 | font-size: 18px; 333 | cursor: pointer; 334 | padding: 4px; 335 | border-radius: 50%; 336 | width: 20px; 337 | height: 20px; 338 | display: ${dueDate ? 'flex' : 'none'}; 339 | align-items: center; 340 | justify-content: center; 341 | z-index: 2; 342 | `; 343 | 344 | clearButton.addEventListener('click', (e) => { 345 | e.preventDefault(); 346 | e.stopPropagation(); 347 | dateField.value = ''; 348 | clearButton.style.display = 'none'; 349 | if (timeField) { 350 | timeField.value = ''; 351 | } 352 | }); 353 | 354 | // Show/hide clear button based on input value 355 | dateField.addEventListener('input', () => { 356 | clearButton.style.display = dateField.value ? 'flex' : 'none'; 357 | }); 358 | 359 | dateWrapper.appendChild(dateLabel); 360 | dateWrapper.appendChild(dateField); 361 | dateWrapper.appendChild(clearButton); 362 | dateContainer.appendChild(dateWrapper); 363 | flexContainer.appendChild(dateContainer); 364 | 365 | // Create time input if datetime is supported 366 | if (entitySupportsFeature(entityState, 32)) { 367 | const timeContainer = document.createElement('div'); 368 | timeContainer.style.cssText = `position: relative; min-width: 120px;`; 369 | 370 | const timeWrapper = document.createElement('div'); 371 | timeWrapper.style.cssText = ` 372 | position: relative; 373 | background: var(--mdc-text-field-fill-color, #f5f5f5); 374 | border-radius: 4px 4px 0 0; 375 | min-height: 56px; 376 | display: flex; 377 | align-items: center; 378 | `; 379 | 380 | timeField = document.createElement('input'); 381 | timeField.type = 'time'; 382 | timeField.value = dueTime; 383 | timeField.style.cssText = ` 384 | width: 100%; 385 | height: 56px; 386 | padding: 20px 12px 6px 12px; 387 | border: none; 388 | border-bottom: 1px solid var(--divider-color); 389 | border-radius: 0; 390 | background: transparent; 391 | color: var(--primary-text-color); 392 | font-family: var(--mdc-typography-subtitle1-font-family, inherit); 393 | font-size: var(--mdc-typography-subtitle1-font-size, 1rem); 394 | line-height: var(--mdc-typography-subtitle1-line-height, 1.75rem); 395 | box-sizing: border-box; 396 | outline: none; 397 | transition: border-bottom-color 0.15s ease; 398 | -webkit-appearance: none; 399 | -moz-appearance: textfield; 400 | `; 401 | 402 | // Create floating label for time field 403 | const timeLabel = document.createElement('span'); 404 | timeLabel.textContent = 'Time'; 405 | timeLabel.style.cssText = ` 406 | position: absolute; 407 | left: 12px; 408 | top: 8px; 409 | font-size: 12px; 410 | color: var(--secondary-text-color); 411 | pointer-events: none; 412 | transition: all 0.2s ease; 413 | `; 414 | 415 | timeWrapper.appendChild(timeLabel); 416 | timeWrapper.appendChild(timeField); 417 | timeContainer.appendChild(timeWrapper); 418 | flexContainer.appendChild(timeContainer); 419 | } 420 | 421 | dueSection.appendChild(flexContainer); 422 | content.appendChild(dueSection); 423 | 424 | debugLog('Custom due date section added with native HA textfield styling'); 425 | } 426 | 427 | // Add focus trap for better accessibility 428 | const setupFocusTrap = () => { 429 | const focusableElements = dialog.querySelectorAll( 430 | 'ha-textfield, ha-checkbox, input, button, ha-button' 431 | ); 432 | if (focusableElements.length === 0) return; 433 | 434 | const firstFocusable = focusableElements[0]; 435 | const lastFocusable = focusableElements[focusableElements.length - 1]; 436 | 437 | dialog.addEventListener('keydown', (e) => { 438 | if (e.key === 'Tab') { 439 | if (e.shiftKey && document.activeElement === firstFocusable) { 440 | e.preventDefault(); 441 | lastFocusable.focus(); 442 | } else if (!e.shiftKey && document.activeElement === lastFocusable) { 443 | e.preventDefault(); 444 | firstFocusable.focus(); 445 | } 446 | } 447 | }); 448 | }; 449 | 450 | // Set up focus trap after dialog is added to DOM 451 | setTimeout(setupFocusTrap, 100); 452 | 453 | dialog.appendChild(content); 454 | 455 | // Delete button (if item exists and supports delete) 456 | if (item && supportsDelete) { 457 | const deleteButton = this._createCustomDeleteButton('Delete item', 'secondaryAction'); 458 | 459 | deleteButton.addEventListener('click', async () => { 460 | const confirmed = await this.showDeleteConfirmationDialog(item.summary); 461 | if (confirmed) { 462 | deleteTodoItemFromDialog(entityId, item, this._hass); 463 | this.closeDialog(dialog); 464 | } 465 | }); 466 | 467 | dialog.appendChild(deleteButton); 468 | } 469 | 470 | // Cancel button 471 | const cancelButton = this._createRegularButton('Cancel', 'primaryAction'); 472 | cancelButton.addEventListener('click', () => { 473 | this.closeDialog(dialog); 474 | }); 475 | dialog.appendChild(cancelButton); 476 | 477 | // Save button 478 | const saveText = item ? 'Save item' : 'Add'; 479 | const saveButton = this._createRegularButton(saveText, 'primaryAction'); 480 | saveButton.addEventListener('click', async () => { 481 | const summary = summaryField.value.trim(); 482 | if (!summary) { 483 | summaryField.reportValidity(); 484 | return; 485 | } 486 | 487 | // Handle due date/time properly 488 | let dueValue = ''; 489 | if (dateField?.value) { 490 | if (timeField?.value) { 491 | // Create a proper Date object and convert to ISO string 492 | const dateTimeString = `${dateField.value}T${timeField.value}:00`; 493 | try { 494 | const dateObj = new Date(dateTimeString); 495 | dueValue = dateObj.toISOString(); 496 | } catch (e) { 497 | console.error('Invalid date/time combination'); 498 | dueValue = dateField.value; // fallback to date only 499 | } 500 | } else { 501 | // Date only 502 | dueValue = dateField.value; 503 | } 504 | } 505 | 506 | // Build data object conditionally based on entity support 507 | const data = { 508 | summary: summary, 509 | completed: checkbox?.checked || false 510 | }; 511 | 512 | // Only include description if entity supports it 513 | if (supportsDescription) { 514 | data.description = descriptionField?.value; 515 | } 516 | 517 | // Only include dueDate if entity supports it 518 | if (supportsDueDate) { 519 | data.dueDate = dueValue; 520 | } 521 | 522 | const success = await this.handleDialogSave(entityId, item, data); 523 | 524 | if (success) { 525 | this.closeDialog(dialog); 526 | } 527 | }); 528 | dialog.appendChild(saveButton); 529 | 530 | // Keyboard handlers 531 | summaryField.addEventListener('keydown', (e) => { 532 | if (e.key === 'Enter') { 533 | e.preventDefault(); 534 | const summary = summaryField.value.trim(); 535 | if (summary) { 536 | // Handle due date/time properly 537 | let dueValue = ''; 538 | if (dateField?.value) { 539 | if (timeField?.value) { 540 | // Create a proper Date object and convert to ISO string 541 | const dateTimeString = `${dateField.value}T${timeField.value}:00`; 542 | try { 543 | const dateObj = new Date(dateTimeString); 544 | dueValue = dateObj.toISOString(); 545 | } catch (e) { 546 | console.error('Invalid date/time combination'); 547 | dueValue = dateField.value; // fallback to date only 548 | } 549 | } else { 550 | // Date only 551 | dueValue = dateField.value; 552 | } 553 | } 554 | 555 | // Build data object conditionally based on entity support 556 | const data = { 557 | summary: summary, 558 | completed: checkbox?.checked || false 559 | }; 560 | 561 | // Only include description if entity supports it 562 | if (supportsDescription) { 563 | data.description = descriptionField?.value; 564 | } 565 | 566 | // Only include dueDate if entity supports it 567 | if (supportsDueDate) { 568 | data.dueDate = dueValue; 569 | } 570 | 571 | this.handleDialogSave(entityId, item, data).then((success) => { 572 | if (success) this.closeDialog(dialog); 573 | }); 574 | } 575 | } 576 | }); 577 | 578 | // Handle dialog close 579 | dialog.addEventListener('closed', () => { 580 | this.onDialogClosed(dialog); 581 | }); 582 | 583 | // Add to document and focus 584 | document.body.appendChild(dialog); 585 | 586 | // Focus after dialog is rendered 587 | setTimeout(() => { 588 | summaryField.focus(); 589 | }, 100); 590 | } 591 | 592 | /** 593 | * Close a specific dialog 594 | * @param {HTMLElement} dialog - Dialog to close 595 | */ 596 | closeDialog(dialog) { 597 | if (dialog && dialog.open) { 598 | dialog.open = false; 599 | dialog.close(); 600 | } 601 | } 602 | 603 | /** 604 | * Close the current dialog 605 | */ 606 | closeCurrentDialog() { 607 | if (this.currentDialog) { 608 | this.closeDialog(this.currentDialog); 609 | this.currentDialog = null; 610 | } 611 | } 612 | 613 | /** 614 | * Handle when a dialog is closed 615 | * @param {HTMLElement} dialog - Closed dialog 616 | */ 617 | onDialogClosed(dialog) { 618 | // Clean up the dialog from DOM 619 | if (dialog.parentNode) { 620 | dialog.parentNode.removeChild(dialog); 621 | } 622 | 623 | // Clear current dialog reference if this was the current one 624 | if (this.currentDialog === dialog) { 625 | this.currentDialog = null; 626 | } 627 | } 628 | 629 | /** 630 | * Handle saving from the dialog with error handling 631 | * @param {string} entityId - Entity ID 632 | * @param {Object} item - Original item (null for new items) 633 | * @param {Object} data - Form data 634 | * @returns {Promise} Success status 635 | */ 636 | async handleDialogSave(entityId, item, data) { 637 | if (!data.summary) { 638 | return false; 639 | } 640 | 641 | try { 642 | if (item) { 643 | await updateTodoItemFromDialog(entityId, item, data, this._hass); 644 | } else { 645 | await addTodoItemFromDialog(entityId, data, this._hass); 646 | } 647 | return true; 648 | } catch (error) { 649 | debugLog('Error saving todo item:', error); 650 | return false; 651 | } 652 | } 653 | 654 | /** 655 | * Show delete confirmation dialog 656 | * @param {string} itemSummary - Item summary for confirmation 657 | * @returns {Promise} True if confirmed 658 | */ 659 | async showDeleteConfirmationDialog(itemSummary) { 660 | return new Promise((resolve) => { 661 | const confirmDialog = document.createElement('ha-dialog'); 662 | confirmDialog.heading = 'Confirm Deletion'; 663 | confirmDialog.open = true; 664 | 665 | const content = document.createElement('div'); 666 | content.style.padding = '16px'; 667 | content.textContent = `Are you sure you want to delete "${itemSummary}"?`; 668 | confirmDialog.appendChild(content); 669 | 670 | // Cancel button first (secondary action) 671 | const cancelButton = this._createRegularButton('Cancel', 'secondaryAction'); 672 | cancelButton.addEventListener('click', () => { 673 | confirmDialog.close(); 674 | resolve(false); 675 | }); 676 | 677 | // Delete button with red styling 678 | const confirmButton = this._createCustomDeleteButton('Delete', 'primaryAction'); 679 | confirmButton.addEventListener('click', () => { 680 | confirmDialog.close(); 681 | resolve(true); 682 | }); 683 | 684 | confirmDialog.appendChild(cancelButton); 685 | confirmDialog.appendChild(confirmButton); 686 | 687 | confirmDialog.addEventListener('closed', () => { 688 | if (confirmDialog.parentNode) { 689 | confirmDialog.parentNode.removeChild(confirmDialog); 690 | } 691 | resolve(false); 692 | }); 693 | 694 | document.body.appendChild(confirmDialog); 695 | }); 696 | } 697 | 698 | /** 699 | * Show delete confirmation dialog for delete completed items 700 | * @param {string} entityId - Entity ID for the todo list 701 | */ 702 | showDeleteCompletedConfirmation(entityId) { 703 | // Create confirmation dialog 704 | const dialog = document.createElement('ha-dialog'); 705 | dialog.heading = 'Confirm Deletion'; 706 | dialog.open = true; 707 | 708 | // Create content container 709 | const content = document.createElement('div'); 710 | content.style.padding = '16px'; 711 | content.textContent = 'Are you sure you want to delete all completed items from the list?'; 712 | dialog.appendChild(content); 713 | 714 | // Create cancel button first (secondary action) 715 | const cancelButton = this._createRegularButton('Cancel', 'secondaryAction'); 716 | cancelButton.addEventListener('click', () => { 717 | dialog.close(); 718 | }); 719 | 720 | // Create confirm button with red styling 721 | const confirmButton = this._createCustomDeleteButton('Delete', 'primaryAction'); 722 | confirmButton.addEventListener('click', () => { 723 | dialog.close(); 724 | // Import deleteCompletedItems here to avoid circular dependency 725 | import('../features/TodoOperations.js').then((module) => { 726 | module.deleteCompletedItems(entityId, this._hass); 727 | }); 728 | }); 729 | 730 | // Append buttons to dialog 731 | dialog.appendChild(cancelButton); 732 | dialog.appendChild(confirmButton); 733 | 734 | // Handle dialog close 735 | dialog.addEventListener('closed', () => { 736 | if (dialog.parentNode) { 737 | dialog.parentNode.removeChild(dialog); 738 | } 739 | }); 740 | 741 | // Add dialog to document 742 | document.body.appendChild(dialog); 743 | } 744 | } 745 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todo Swipe Card 2 | 3 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/nutteloost/todo-swipe-card/total?label=Downloads) 4 | [![Buy me a beer](https://img.shields.io/badge/Donate-Buy%20me%20a%20beer-yellow?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/nutteloost) 5 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 6 | [![Reddit Profile](https://img.shields.io/badge/Reddit-My%20stuff-orange?logo=reddit)](https://www.reddit.com/user/nutteloost/submitted/) 7 | [![Home Assistant Community Forum](https://img.shields.io/badge/Home%20Assistant-Community%20Forum-blue?logo=home-assistant)](https://community.home-assistant.io/t/simple-swipe-card-a-custom-card-for-easy-card-navigation/888415) 8 | 9 | 10 | A specialized swipe card for todo lists in Home Assistant with custom styling. 11 | 12 | Example 13 | 14 | Todo Swipe Card is a customizable container card for Home Assistant specifically designed for to-do lists. It allows you to place multiple to-do lists in a single space and navigate between them with intuitive swipe gestures. The card features built-in styling for a clean, modern look and optimizes dashboard space by grouping related to-do lists together. With mobile-friendly touch and mouse navigation, pagination indicators, adjustable card spacing, and customizable background images, Todo Swipe Card enhances both functionality and user experience with minimal configuration. 15 | 16 | ## Features 17 | - Swipe between multiple to-do lists 18 | - Built-in styling with customizable background images 19 | - Pagination dots 20 | - Configurable card spacing 21 | - Options to show/hide completed items 22 | - Delete completed items button with optional confirmation dialog 23 | - Drag-and-drop reordering (for supported integrations) 24 | - Visual editor support 25 | 26 | ## Requirements 27 | - Home Assistant 2023.4 or later 28 | 29 | ## Installation 30 | 31 | ### HACS (Recommended) 32 | 1. Open HACS 33 | 2. Search for "Todo Swipe Card" and install it 34 | 35 | Or click this button to open the repository page in HACS: 36 | 37 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?repository=todo-swipe-card&category=integration&owner=nutteloost) 38 | 39 | ### HACS (Manual) 40 | 1. Open HACS 41 | 2. Go to "Frontend" section 42 | 3. Click on the three dots in the top right corner 43 | 4. Select "Custom repositories" 44 | 5. Add this repository URL: `https://github.com/nutteloost/todo-swipe-card` 45 | 6. Click "Add" 46 | 7. Search for "Todo Swipe Card" and install it 47 | 48 | ### Manual Installation 49 | 1. Download `todo-swipe-card.js` from the latest release 50 | 2. Copy it to `config/www/todo-swipe-card/todo-swipe-card.js` 51 | 3. Add the following to your configuration.yaml: 52 | ```yaml 53 | lovelace: 54 | resources: 55 | - url: /local/todo-swipe-card/todo-swipe-card.js 56 | type: module 57 | ``` 58 | 4. Restart Home Assistant 59 | 60 | ## Visual Editor 61 | 62 | The Todo Swipe Card includes a visual editor that appears when you add or edit the card through the Home Assistant UI. Features include: 63 | - Add/remove to-do lists 64 | - Visual toggle for pagination dots 65 | - Simple number input for card spacing 66 | - Background image configuration for each to-do list 67 | - Display order setting of todo list items per list 68 | - Display options for completed items, add buttons, and more 69 | - Real-time preview of changes 70 | 71 | #### Search for 'Todo Swipe Card' 72 | 73 | 74 | #### Edit the card 75 | 76 | 77 | ## Configuration 78 | This card can be configured using the visual editor or YAML. 79 | 80 | ### Options 81 | | Name | Type | Default | Description | 82 | |------|------|---------|-------------| 83 | | `entities` | list | Required | List of todo entities to display | 84 | | `show_pagination` | boolean | `true` | Show/hide pagination dots | 85 | | `show_icons` | boolean | `false` | Show/hide icons for each todo list | 86 | | `show_addbutton` | boolean | `false` | Show/hide the "+" button next to the add field | 87 | | `show_create` | boolean | `true` | Show/hide the add item input field | 88 | | `show_completed` | boolean | `false` | Show/hide completed items | 89 | | `show_completed_menu` | boolean | `false` | Show/hide delete completed items button | 90 | | `enable_search` | boolean | `false` | Enable or disable search functionality | 91 | | `clear_search_on_uncheck` | boolean | `false` | Clear search filter when unchecking a completed item | 92 | | `delete_confirmation` | boolean | `false` | Show confirmation dialog when deleting completed items | 93 | | `card_spacing` | number | `15` | Space between cards in pixels | 94 | 95 | 96 | ### Entity Configuration Options 97 | | Name | Type | Default | Description | 98 | |------|------|---------|-------------| 99 | | `entity` | string | Required | Todo list entity ID | 100 | | `show_title` | boolean | `false` | Show custom title above todo list | 101 | | `title` | string | Optional | Custom title text (Only used if show_title is true) | 102 | | `background_image` | string | Optional | Background image URL for this entity | 103 | | `display_order` | string | none | Sort order: `none`, `alpha_asc`, `alpha_desc`, `duedate_asc`, `duedate_desc` | 104 | | `icon` | string | Optional | Custom icon for this todo list (e.g., `mdi:cart-variant`) | 105 | 106 | 107 | ### Example Configuration 108 | 109 | #### Simple configuration 110 | ```yaml 111 | type: custom:todo-swipe-card 112 | entities: 113 | - todo.shopping_list 114 | - todo.home_tasks 115 | - todo.work_projects 116 | show_pagination: true 117 | show_completed: true 118 | card_spacing: 15 119 | ``` 120 | 121 | #### Advanced Configuration 122 | 123 | ```yaml 124 | type: custom:todo-swipe-card 125 | entities: 126 | - entity: todo.shopping_list 127 | background_image: /local/images/shopping_bg.jpg 128 | show_title: true 129 | title: "Shopping List" 130 | display_order: alpha_asc 131 | icon: mdi:cart-variant 132 | - entity: todo.home_tasks 133 | background_image: /local/images/home_bg.jpg 134 | show_title: true 135 | title: "Home Tasks" 136 | display_order: duedate_asc 137 | icon: mdi:home-variant 138 | - entity: todo.work_projects 139 | display_order: none 140 | icon: mdi:briefcase-variant 141 | show_pagination: true 142 | show_icons: true 143 | show_addbutton: true 144 | show_create: true 145 | show_completed: true 146 | show_completed_menu: true 147 | enable_search: true 148 | clear_search_on_uncheck: true 149 | delete_confirmation: true 150 | card_spacing: 10 151 | ``` 152 | 153 | ## Drag-and-Drop Reordering 154 | 155 | The Todo Swipe Card supports drag-and-drop reordering of todo items for integrations that implement the `MOVE_TODO_ITEM` feature. 156 | 157 | ### How to Use 158 | 1. Simply click and hold on any todo item 159 | 2. Drag it up or down to your desired position 160 | 3. Visual indicators will show where the item will be placed 161 | 4. Release to complete the move 162 | 163 | **Note**: The Shopping List integration and some third-party integrations may not support drag-and-drop reordering. In these cases, items will display without drag-and-drop functionality. 164 | 165 | ## Customizing and Theming 166 | The Todo Swipe Card provides extensive customization capabilities through two primary methods: Home Assistant themes and card-mod styling. The card supports over fourty CSS variables that control every aspect of its appearance, from basic colors and typography to sophisticated pagination styling and transition effects. 167 | 168 | **Simplified Customization Approach**: Todo Swipe Card includes CSS variables that make customization much easier compared to traditional card-mod styling. Instead of having to figure out complex CSS selectors or inspect the card's internal structure, you can simply use these predefined variables to customize colors, sizes, and other visual elements. This means you can create great-looking themes without needing to be a CSS expert or spending time hunting down the right selectors to target specific elements. 169 | 170 | When combining theme-based styling with card-mod overrides, remember that card-mod styles take precedence over theme variables. This hierarchy allows you to establish baseline styling through themes while maintaining the flexibility to customize individual card instances as needed. 171 | 172 | ### Method 1: Home Assistant Themes 173 | Create or modify a theme in your Home Assistant configuration to apply styling across all instances of the Todo Swipe Card. This method is ideal for consistent styling throughout your dashboard. 174 | 175 | Add the following to your `configuration.yaml`: 176 | 177 | ```yaml 178 | frontend: 179 | themes: 180 | todo_custom_theme: 181 | # Your existing theme variables 182 | primary-color: "#3498db" 183 | # Todo Swipe Card specific variables 184 | todo-swipe-card-text-color: "#2c3e50" 185 | todo-swipe-card-pagination-dot-active-color: "#e74c3c" 186 | # Add any other variables from the complete CSS reference 187 | ``` 188 | 189 | ### Method 2: Card-Mod Styling 190 | 191 | Apply styling directly to individual card instances using card-mod. This method provides maximum flexibility and allows for unique styling of specific cards. 192 | 193 | ```yaml 194 | type: custom:todo-swipe-card 195 | entities: 196 | - todo.shopping_list 197 | card_mod: 198 | style: | 199 | :host { 200 | --todo-swipe-card-text-color: #ffffff; 201 | --todo-swipe-card-background: linear-gradient(45deg, #667eea, #764ba2); 202 | } 203 | ``` 204 | 205 | ### Complete CSS Variables Reference 206 | 207 | #### Core Appearance 208 | ```yaml 209 | --todo-swipe-card-background: /* Main card background color or gradient */ 210 | --todo-swipe-card-text-color: /* Primary text color for all todo items, excluding descriptions and due dates */ 211 | ``` 212 | 213 | #### Typography and Layout 214 | ```yaml 215 | --todo-swipe-card-font-size: /* Base font size for todo items (default: 11px) */ 216 | --todo-swipe-card-item-font-weight: /* Font weight for the main text of a todo item (e.g., normal, bold, 500) */ 217 | --todo-swipe-card-item-height: /* Minimum height of individual todo items (default: calc(var(--todo-swipe-card-font-size, 11px) + 8px)) */ 218 | --todo-swipe-card-item-margin: /* Spacing between checkbox and todo item text (default: 5px) */ 219 | --todo-swipe-card-item-align: /* Vertical alignment of todo items: flex-start, center, flex-end (default: flex-start) */ 220 | --todo-swipe-card-line-height: /* Line height for main todo text when it wraps to multiple lines (default: 1.3) */ 221 | --todo-swipe-card-item-spacing: /* Consistent margin between todo items (default: 1px) */ 222 | --todo-swipe-card-summary-margin-top: /* Space above the main todo item text (default: 3px) */ 223 | --todo-swipe-card-checkbox-margin-top: /* Top margin for checkboxes for fine positioning (default: 1px) */ 224 | ``` 225 | 226 | #### Due Date Styling 227 | ```yaml 228 | --todo-swipe-card-font-size-due-date: /* Font size for due dates and associated icon (default: 11px) */ 229 | --todo-swipe-card-font-weight-due-date: /* Font weight for the due date text */ 230 | --todo-swipe-card-font-color-due-date: /* Color of the due dates and associated icon */ 231 | --todo-swipe-card-font-color-due-date-overdue: /* Color of overdue due dates text and associated icon */ 232 | --todo-swipe-card-due-date-margin-top: /* Space above due date (default: 2px) */ 233 | --todo-swipe-card-due-icon-size: /* Size of the clock icon next to due dates (default: 14px) */ 234 | ``` 235 | 236 | #### Description Styling 237 | ```yaml 238 | --todo-swipe-card-font-size-description: /* Font size of the description text of todo items */ 239 | --todo-swipe-card-font-weight-description: /* Font weight for the description text of a todo item */ 240 | --todo-swipe-card-font-color-description: /* Color of the description text of todo items */ 241 | --todo-swipe-card-description-margin-top: /* Space above description text (default: 1px) */ 242 | ``` 243 | 244 | #### Title Configuration 245 | ```yaml 246 | --todo-swipe-card-title-height: /* Height of entity titles (default: 40px) */ 247 | --todo-swipe-card-title-background: /* Background color of entity titles */ 248 | --todo-swipe-card-title-color: /* Text color of entity titles */ 249 | --todo-swipe-card-title-font-size: /* Font size of entity titles (default: 16px) */ 250 | --todo-swipe-card-title-font-weight: /* Font weight of entity titles (default: 500) */ 251 | --todo-swipe-card-title-border-color: /* Border color below entity titles */ 252 | --todo-swipe-card-title-border-width: /* Border width below entity titles (default: 1px) */ 253 | --todo-swipe-card-title-padding-horizontal: /* Horizontal padding of entity titles (default: 16px) */ 254 | --todo-swipe-card-title-align: /* Title alignment: left, center, right (default: center) */ 255 | ``` 256 | 257 | #### Icon Styling 258 | ```yaml 259 | --todo-swipe-card-icon-size: /* Size of todo list icons (default: 48px) */ 260 | --todo-swipe-card-icon-color: /* Color of todo list icons with opacity support */ 261 | --todo-swipe-card-icon-opacity: /* Opacity of todo list icons (default: 0.6) */ 262 | --todo-swipe-card-icon-right: /* Distance of icon from right edge (default: 16px) */ 263 | --todo-swipe-card-icon-bottom: /* Distance of icon from bottom edge (default: 8px) */ 264 | ``` 265 | 266 | #### Checkbox Styling 267 | ```yaml 268 | --todo-swipe-card-checkbox-color: /* Color of unchecked checkboxes, use rgba values to also control opacity (rgba(255, 0, 0, 0.6);) */ 269 | --todo-swipe-card-checkbox-checked-color: /* Color of checked checkboxes (default: var(--primary-color)) */ 270 | --todo-swipe-card-checkbox-checkmark-color: /* Color of the checkmark inside checked boxes */ 271 | --todo-swipe-card-checkbox-size: /* Size of checkbox elements (default: 18px) */ 272 | ``` 273 | 274 | #### Input Field Styling 275 | ```yaml 276 | --todo-swipe-card-input-font-weight: /* Font weight for the text typed into the 'Add item' field */ 277 | --todo-swipe-card-placeholder-font-weight: /* Font weight for the placeholder text in the 'Add item' field */ 278 | --todo-swipe-card-placeholder-color: /* Color of 'Add item' text in input fields */ 279 | --todo-swipe-card-placeholder-opacity: /* Opacity of 'Add item' text (default: 1) */ 280 | ``` 281 | 282 | #### Search Counter Styling 283 | ```yaml 284 | --todo-swipe-card-search-counter-font-size: /* Font size for search results counter (default: 12px) */ 285 | --todo-swipe-card-search-counter-color: /* Color of search results counter text */ 286 | ``` 287 | 288 | #### Pagination Customization 289 | ```yaml 290 | --todo-swipe-card-pagination-dot-size: /* Diameter of pagination dots (default: 8px) */ 291 | --todo-swipe-card-pagination-dot-active-color: /* Color of the currently active pagination dot */ 292 | --todo-swipe-card-pagination-dot-inactive-color: /* Color of inactive pagination dots */ 293 | --todo-swipe-card-pagination-dot-spacing: /* Horizontal space between pagination dots (default: 4px) */ 294 | --todo-swipe-card-pagination-dot-border-radius: /* Border radius of pagination dots (default: 50%) */ 295 | --todo-swipe-card-pagination-dot-active-size-multiplier: /* Size multiplier for active dots (default: 1) */ 296 | --todo-swipe-card-pagination-bottom: /* Distance of pagination from bottom edge (default: 8px) */ 297 | --todo-swipe-card-pagination-background: /* Background color of the pagination area */ 298 | --todo-swipe-card-pagination-dot-active-opacity: /* Opacity of active pagination dot (default: 1) */ 299 | --todo-swipe-card-pagination-dot-inactive-opacity: /* Opacity of inactive pagination dot (default: 0.6) */ 300 | ``` 301 | 302 | #### Animation and Transitions 303 | ```yaml 304 | --todo-swipe-card-transition-speed: /* Duration of swipe animations (default: 0.3s) */ 305 | --todo-swipe-card-transition-easing: /* Easing function for transitions (default: ease-out) */ 306 | ``` 307 | 308 | #### Interactive Elements 309 | ```yaml 310 | --todo-swipe-card-delete-button-top: /* Manual positioning of delete button from top */ 311 | --todo-swipe-card-delete-button-color: /* Color of the delete completed items button */ 312 | --todo-swipe-card-add-button-color: /* Color of the add item button */ 313 | ``` 314 | 315 | ### Styling Examples 316 | 317 | #### Example 1 318 | 319 | Example 1 320 | 321 |
322 | Example 1 (Advanced) Configuration: 323 | 324 | ```yaml 325 | type: custom:todo-swipe-card 326 | entities: 327 | - entity: todo.shipping_list 328 | show_title: true 329 | title: 🛒 Shopping List 330 | display_order: alpha_asc 331 | icon: mdi:cart-variant 332 | - entity: todo.work_projects 333 | show_title: true 334 | title: 💼 Work Projects 335 | display_order: duedate_desc 336 | icon: mdi:hammer-screwdriver 337 | card_spacing: 10 338 | show_pagination: true 339 | show_icons: true 340 | show_create: true 341 | show_addbutton: true 342 | show_completed: true 343 | show_completed_menu: true 344 | enable_search: true 345 | delete_confirmation: true 346 | card_mod: 347 | style: | 348 | :host { 349 | --todo-swipe-card-background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 350 | --todo-swipe-card-text-color: #ffffff; 351 | --todo-swipe-card-item-spacing: 8px; 352 | 353 | /* Typography */ 354 | --todo-swipe-card-font-size: 11px; 355 | --todo-swipe-card-font-size-due-date: 10px; 356 | 357 | /* Title styling - left aligned */ 358 | --todo-swipe-card-title-height: 35px; 359 | --todo-swipe-card-title-background: linear-gradient(135deg, #667eea 0%, #764ba2 10%); 360 | --todo-swipe-card-title-color: #ffffff; 361 | --todo-swipe-card-title-font-size: 18px; 362 | --todo-swipe-card-title-font-weight: 400; 363 | --todo-swipe-card-title-align: flex-start; 364 | --todo-swipe-card-title-border-color: rgba(255, 255, 255, 0.2); 365 | --todo-swipe-card-title-border-width: 3px; 366 | 367 | /* Due date styling */ 368 | --todo-swipe-card-font-color-due-date: rgba(255, 255, 255, 0.8); 369 | --todo-swipe-card-font-color-due-date-overdue: #ff6b6b; 370 | --todo-swipe-card-font-color-description: rgba(255, 255, 255, 0.7); 371 | 372 | /* Tighter line spacing */ 373 | --todo-swipe-card-line-height: 1.2; 374 | --todo-swipe-card-description-margin-top: 2px; 375 | --todo-swipe-card-due-date-margin-top: 1px; 376 | 377 | /* Modern checkbox styling */ 378 | --todo-swipe-card-checkbox-color: rgba(255, 255, 255, 0.3); 379 | --todo-swipe-card-checkbox-checked-color: #4ecdc4; 380 | --todo-swipe-card-checkbox-checkmark-color: #ffffff; 381 | --todo-swipe-card-checkbox-size: 22px; 382 | 383 | /* Input field styling */ 384 | --todo-swipe-card-placeholder-color: rgba(255, 255, 255, 0.7); 385 | --todo-swipe-card-placeholder-opacity: 0.5; 386 | 387 | /* Interactive elements */ 388 | --todo-swipe-card-add-button-color: #4ecdc4; 389 | --todo-swipe-card-delete-button-color: #ff6b6b; 390 | 391 | /* Pagination Dots */ 392 | --todo-swipe-card-pagination-dot-size: 10px; 393 | --todo-swipe-card-pagination-dot-active-color: #4ecdc4; 394 | --todo-swipe-card-pagination-dot-inactive-color: rgba(255, 255, 255, 0.4); 395 | --todo-swipe-card-pagination-dot-spacing: 2px; 396 | --todo-swipe-card-pagination-bottom: 12px; 397 | --todo-swipe-card-pagination-dot-active-size-multiplier: 1.5; 398 | } 399 | ``` 400 |
401 | 402 | 403 | #### Example 2 404 | 405 | Example 2 406 | 407 |
408 | Example 2 Configuration 409 | 410 | ```yaml 411 | card_mod: 412 | style: | 413 | :host { 414 | --todo-swipe-card-background: #fafafa; 415 | --todo-swipe-card-text-color: #212121; 416 | --todo-swipe-card-font-size: 13px; 417 | --todo-swipe-card-font-size-due-date: 10px; 418 | --todo-swipe-card-item-margin: 6px; 419 | 420 | --todo-swipe-card-checkbox-color: rgba(97, 97, 97, 0.3); 421 | --todo-swipe-card-checkbox-checked-color: transparent; 422 | --todo-swipe-card-checkbox-checkmark-color: transparent; 423 | 424 | --todo-swipe-card-field-line-color: #e0e0e0; 425 | --todo-swipe-card-placeholder-color: #757575; 426 | --todo-swipe-card-add-button-color: #4caf50; 427 | --todo-swipe-card-delete-button-color: red; 428 | 429 | --todo-swipe-card-pagination-dot-size: 8px; 430 | --todo-swipe-card-pagination-dot-border-radius: 1px; 431 | --todo-swipe-card-pagination-dot-active-color: #4caf50; 432 | --todo-swipe-card-pagination-dot-spacing: 6px; 433 | --todo-swipe-card-pagination-bottom: 10px; 434 | 435 | --todo-swipe-card-transition-speed: 0.25s; 436 | --todo-swipe-card-transition-easing: ease-out; 437 | } 438 | ``` 439 |
440 | 441 | #### Example 3 442 | 443 | Example 3 444 | 445 |
446 | Example 3 Configuration 447 | 448 | ```yaml 449 | card_mod: 450 | style: | 451 | :host { 452 | --todo-swipe-card-background: pink; 453 | --todo-swipe-card-text-color: grey; 454 | --todo-swipe-card-font-size: 16px; 455 | 456 | --todo-swipe-card-checkbox-color: rgba(255, 0, 0, 0.75); 457 | --todo-swipe-card-checkbox-checked-color: green; 458 | --todo-swipe-card-checkbox-checkmark-color: purple; 459 | 460 | --todo-swipe-card-pagination-dot-spacing: 13px; 461 | --todo-swipe-card-pagination-dot-active-color: green; 462 | 463 | --todo-swipe-card-add-button-color: purple; 464 | --todo-swipe-card-delete-button-color: purple; 465 | } 466 | ``` 467 |
468 | 469 | 470 | #### Example 4 - Advanced Per-Card Title Styling 471 | 472 | Example 4 473 | 474 |
475 | Example 4 (Advanced Per-Card Title Styling): 476 | 477 | For even more customization, you can apply different title styles to individual cards using CSS selectors. This allows each todo list to have its own unique title appearance while maintaining consistent base styling. 478 | 479 | The following example shows how to style titles differently for each card using the `.slide:nth-child()` selector: 480 | 481 | ```yaml 482 | card_mod: 483 | style: | 484 | :host { 485 | /* Dark background with neon accents */ 486 | --todo-swipe-card-background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%); 487 | --todo-swipe-card-text-color: #00ff9f; 488 | 489 | /* Title styles */ 490 | --todo-swipe-card-title-height: 35px; 491 | --todo-swipe-card-title-font-size: 18px; 492 | --todo-swipe-card-title-font-weight: 700; 493 | --todo-swipe-card-title-border-width: 4px; 494 | --todo-swipe-card-delete-button-top: 78px; 495 | 496 | /* Neon checkboxes */ 497 | --todo-swipe-card-checkbox-color: rgba(0, 255, 159, 0.3); 498 | --todo-swipe-card-checkbox-checked-color: #ff006e; 499 | --todo-swipe-card-checkbox-checkmark-color: #ffffff; 500 | --todo-swipe-card-checkbox-size: 24px; 501 | 502 | /* Typography */ 503 | --todo-swipe-card-font-size: 13px; 504 | --todo-swipe-card-font-color-due-date: #8338ec; 505 | --todo-swipe-card-font-color-due-date-overdue: #ff006e; 506 | --todo-swipe-card-font-color-description: rgba(0, 255, 159, 0.7); 507 | 508 | /* Glowing icons */ 509 | --todo-swipe-card-icon-color: #00ff9f; 510 | --todo-swipe-card-icon-size: 40px; 511 | --todo-swipe-card-icon-opacity: 0.8; 512 | 513 | /* Neon pagination */ 514 | --todo-swipe-card-pagination-dot-active-color: #ff006e; 515 | --todo-swipe-card-pagination-dot-inactive-color: rgba(131, 56, 236, 0.4); 516 | --todo-swipe-card-pagination-dot-size: 8px; 517 | --todo-swipe-card-pagination-dot-active-size-multiplier: 1.3; 518 | 519 | /* Interactive elements */ 520 | --todo-swipe-card-add-button-color: #00ff9f; 521 | --todo-swipe-card-delete-button-color: #ff006e; 522 | } 523 | 524 | ha-card { 525 | border-style: hidden; 526 | } 527 | 528 | /* First card - Electric Pink */ 529 | .slide:nth-child(1) .todo-swipe-card-external-title { 530 | background: linear-gradient(90deg, #ff006e 0%, #ff0080 100%) !important; 531 | color: #ffffff !important; 532 | border-bottom-color: #ff006e !important; 533 | } 534 | 535 | /* Second card - Neon Green */ 536 | .slide:nth-child(2) .todo-swipe-card-external-title { 537 | background: linear-gradient(90deg, #00ff9f 0%, #00e5ff 100%) !important; 538 | color: #0f0f23 !important; 539 | border-bottom-color: #00ff9f !important; 540 | } 541 | 542 | /* Third card - Electric Blue */ 543 | .slide:nth-child(3) .todo-swipe-card-external-title { 544 | background: linear-gradient(90deg, #3a86ff 0%, #8338ec 100%) !important; 545 | color: #ffffff !important; 546 | border-bottom-color: #3a86ff !important; 547 | } 548 | 549 | /* Fourth card - Cyber Purple */ 550 | .slide:nth-child(4) .todo-swipe-card-external-title { 551 | background: linear-gradient(90deg, #8338ec 0%, #ff006e 100%) !important; 552 | color: #ffffff !important; 553 | border-bottom-color: #8338ec !important; 554 | } 555 | ``` 556 | 557 | #### How it works: 558 | 559 | The `:nth-child()` selector targets specific slides based on their position (1st, 2nd, 3rd, etc.) 560 | Each card can have completely different title styling while sharing the same base configuration. 561 | Don't forget to adjust `--todo-swipe-card-delete-button-top` if you change the title height to ensure proper button positioning (like I did in this example) 562 | 563 | This technique works with any CSS property - background colors, gradients, borders, shadows, and more! 564 |
565 | 566 | ### My Other Custom Cards 567 | 568 | Check out my other custom cards for Home Assistant: 569 | 570 | * [Simple Swipe Card](https://github.com/nutteloost/simple-swipe-card) - A swipeable container card that allows you to add multiple cards and swipe between them 571 | * [Actions Card](https://github.com/nutteloost/actions-card) - Wraps another Home Assistant card to add tap, hold, and double-tap actions 572 | 573 | Enjoying my cards? Consider donating a beer (or two)! It will keep me going. 574 | 575 | [![Buy me a beer](https://img.shields.io/badge/Donate-Buy%20me%20a%20beer-yellow?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/nutteloost) 576 | -------------------------------------------------------------------------------- /src/core/TodoSwipeCard.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from './Dependencies.js'; 2 | import { debugLog } from '../utils/Debug.js'; 3 | import { createBaseStyles } from '../ui/Styles.js'; 4 | import { buildPreview } from '../ui/DomHelpers.js'; 5 | import { addSwipeGestures } from '../features/SwipeGestures.js'; 6 | import { cleanupSearchHandlers } from '../features/SearchFunctionality.js'; 7 | import { createPagination, updatePaginationDots } from '../features/Pagination.js'; 8 | import { DialogManager } from './DialogManager.js'; 9 | import { CardBuilder } from './CardBuilder.js'; 10 | import { SubscriptionManager } from './SubscriptionManager.js'; 11 | import { addTodoItem, toggleTodoItem, deleteCompletedItems } from '../features/TodoOperations.js'; 12 | 13 | /** 14 | * TodoSwipeCard: A custom card for Home Assistant to display multiple todo lists with swipe navigation 15 | * @extends LitElement 16 | */ 17 | export class TodoSwipeCard extends LitElement { 18 | constructor() { 19 | super(); 20 | 21 | this._config = {}; 22 | this._hass = null; 23 | this.cards = []; 24 | this.currentIndex = 0; 25 | this.slideWidth = 0; 26 | this.cardContainer = null; 27 | this.sliderElement = null; 28 | this.paginationElement = null; 29 | this.initialized = false; 30 | this._dynamicStyleElement = null; 31 | this._configUpdateTimer = null; 32 | this._buildPromise = null; 33 | this._lastConfig = null; 34 | this._updateThrottle = null; 35 | this._lastHassUpdate = null; 36 | this._todoSubscriptions = new Map(); // Track todo subscriptions per entity 37 | this._deleteButtonColor = null; 38 | 39 | // Search functionality properties 40 | this._searchStates = new Map(); // Track search state per entity 41 | this._currentSearchText = ''; 42 | this._searchInputHandlers = new Map(); // Track input handlers per card 43 | 44 | this._todoItemsCache = new Map(); // Cache for todo items per entity 45 | this._isAddingItem = false; 46 | 47 | // Initialize dialog manager 48 | this.dialogManager = new DialogManager(this); 49 | 50 | // Initialize card builder 51 | this.cardBuilder = new CardBuilder(this); 52 | 53 | // Initialize subscription manager 54 | this.subscriptionManager = new SubscriptionManager(this); 55 | 56 | // Bind methods to ensure proper context 57 | this._addTodoItem = this._addTodoItem.bind(this); 58 | this._toggleTodoItem = this._toggleTodoItem.bind(this); 59 | this._editTodoItem = this._editTodoItem.bind(this); 60 | } 61 | 62 | /** 63 | * Render method for LitElement compatibility 64 | * @returns {TemplateResult|void} 65 | */ 66 | render() { 67 | // For LitElement, return empty template as we handle rendering manually 68 | return html``; 69 | } 70 | 71 | /** 72 | * Returns default configuration for the card 73 | * @returns {Object} Default configuration 74 | */ 75 | static getStubConfig() { 76 | return { 77 | entities: [], 78 | card_spacing: 15, 79 | show_pagination: true, 80 | show_icons: false, 81 | show_create: true, 82 | show_addbutton: false, 83 | show_completed: false, 84 | show_completed_menu: false, 85 | delete_confirmation: false, 86 | enable_search: false, 87 | clear_search_on_uncheck: false 88 | }; 89 | } 90 | 91 | /** 92 | * Returns the editor element for the card 93 | * @returns {HTMLElement} Card editor element 94 | */ 95 | static getConfigElement() { 96 | return document.createElement('todo-swipe-card-editor'); 97 | } 98 | 99 | /** 100 | * Check if there are valid entities configured 101 | * @returns {boolean} True if valid entities are present 102 | * @private 103 | */ 104 | _hasValidEntities() { 105 | return ( 106 | this._config && 107 | this._config.entities && 108 | Array.isArray(this._config.entities) && 109 | this._config.entities.length > 0 && 110 | this._config.entities.some((entity) => { 111 | if (typeof entity === 'string') { 112 | return entity && entity.trim() !== ''; 113 | } 114 | return entity && entity.entity && entity.entity.trim() !== ''; 115 | }) 116 | ); 117 | } 118 | 119 | /** 120 | * Get entity ID from entity configuration (handles both string and object formats) 121 | * @param {string|Object} entity - Entity configuration 122 | * @returns {string} Entity ID 123 | * @private 124 | */ 125 | _getEntityId(entity) { 126 | if (typeof entity === 'string') { 127 | return entity; 128 | } 129 | return entity?.entity || ''; 130 | } 131 | 132 | /** 133 | * Get entity configuration by ID 134 | * @param {string} entityId - Entity ID to find 135 | * @returns {Object|null} Entity configuration object or null if not found 136 | * @private 137 | */ 138 | _getEntityConfig(entityId) { 139 | if (!this._config?.entities) return null; 140 | 141 | const entity = this._config.entities.find((entity) => this._getEntityId(entity) === entityId); 142 | 143 | if (typeof entity === 'string') { 144 | return { entity: entityId }; 145 | } 146 | 147 | return entity || null; 148 | } 149 | 150 | /** 151 | * Check if configuration actually changed 152 | * @param {Object} newConfig - New configuration 153 | * @returns {boolean} True if config changed 154 | * @private 155 | */ 156 | _hasConfigChanged(newConfig) { 157 | if (!this._lastConfig) return true; 158 | 159 | // Quick string comparison first 160 | const newConfigStr = JSON.stringify(newConfig); 161 | const lastConfigStr = JSON.stringify(this._lastConfig); 162 | 163 | return newConfigStr !== lastConfigStr; 164 | } 165 | 166 | /** 167 | * Apply card-mod styles to the card 168 | * Optimized to avoid redundant operations 169 | * @private 170 | */ 171 | _applyCardModStyles() { 172 | // Ensure we have a valid shadowRoot 173 | if (!this.shadowRoot) return; 174 | 175 | // Create a style element for dynamic styles if it doesn't exist 176 | if (!this._dynamicStyleElement) { 177 | this._dynamicStyleElement = document.createElement('style'); 178 | this.shadowRoot.appendChild(this._dynamicStyleElement); 179 | } 180 | 181 | // Generate CSS based on current config 182 | if (this._config && this._config.card_mod && typeof this._config.card_mod.style === 'string') { 183 | const cssText = this._config.card_mod.style; 184 | 185 | // Check if the style already contains :host selector 186 | if (cssText.includes(':host')) { 187 | // Use the style as-is if it already has :host 188 | this._dynamicStyleElement.textContent = cssText; 189 | } else { 190 | // Wrap in :host if it doesn't have it 191 | this._dynamicStyleElement.textContent = ` 192 | :host { 193 | ${cssText} 194 | } 195 | `; 196 | } 197 | } else if (this._dynamicStyleElement) { 198 | this._dynamicStyleElement.textContent = ''; 199 | } 200 | } 201 | 202 | /** 203 | * Extract and apply transition properties from card_mod styles 204 | * @private 205 | */ 206 | _applyTransitionProperties() { 207 | if (!this.sliderElement || !this._config) return; 208 | 209 | try { 210 | // Default values 211 | let transitionSpeed = '0.3s'; 212 | let transitionEasing = 'ease-out'; 213 | 214 | // Extract transition properties from card_mod style - check both locations 215 | const cardModConfig = this._config.card_mod || this._config.custom_card_mod; 216 | if (cardModConfig && typeof cardModConfig.style === 'string') { 217 | const styleString = cardModConfig.style; 218 | 219 | // Extract transition speed 220 | const speedRegex = /--todo-swipe-card-transition-speed\s*:\s*([^;]+)/i; 221 | const speedMatch = styleString.match(speedRegex); 222 | if (speedMatch && speedMatch[1]) { 223 | transitionSpeed = speedMatch[1].trim(); 224 | } 225 | 226 | // Extract transition easing 227 | const easingRegex = /--todo-swipe-card-transition-easing\s*:\s*([^;]+)/i; 228 | const easingMatch = styleString.match(easingRegex); 229 | if (easingMatch && easingMatch[1]) { 230 | transitionEasing = easingMatch[1].trim(); 231 | } 232 | 233 | // Extract delete button color 234 | const deleteButtonRegex = /--todo-swipe-card-delete-button-color\s*:\s*([^;]+)/i; 235 | const deleteButtonMatch = styleString.match(deleteButtonRegex); 236 | if (deleteButtonMatch && deleteButtonMatch[1]) { 237 | this._deleteButtonColor = deleteButtonMatch[1].trim(); 238 | this._applyDeleteButtonColor(); 239 | } 240 | } 241 | 242 | // Apply directly to the slider element with null check 243 | if (this.sliderElement && this.sliderElement.style) { 244 | const transitionValue = `transform ${transitionSpeed} ${transitionEasing}`; 245 | this.sliderElement.style.transition = transitionValue; 246 | 247 | // Store the values for later use 248 | this._transitionSpeed = transitionSpeed; 249 | this._transitionEasing = transitionEasing; 250 | } 251 | } catch (e) { 252 | console.error('Error applying transition properties:', e); 253 | } 254 | } 255 | 256 | /** 257 | * Apply delete button color to all existing delete buttons 258 | * @private 259 | */ 260 | _applyDeleteButtonColor() { 261 | if (!this._deleteButtonColor) return; 262 | 263 | // Find all delete buttons and apply the color 264 | const deleteButtons = this.shadowRoot.querySelectorAll('.delete-completed-button'); 265 | deleteButtons.forEach((button) => { 266 | button.style.color = this._deleteButtonColor; 267 | 268 | // Also apply to the SVG inside 269 | const svg = button.querySelector('svg'); 270 | if (svg) { 271 | svg.style.fill = this._deleteButtonColor; 272 | } 273 | }); 274 | } 275 | 276 | /** 277 | * Set card configuration with improved debouncing 278 | * @param {Object} config - Card configuration 279 | */ 280 | setConfig(config) { 281 | debugLog('setConfig called with:', JSON.stringify(config)); 282 | 283 | // Ensure entities is an array with at least one item 284 | let entities = config.entities || []; 285 | if (!Array.isArray(entities)) { 286 | // Convert from old object format if needed 287 | if (typeof entities === 'object') { 288 | entities = Object.values(entities); 289 | } else if (typeof entities === 'string') { 290 | entities = [entities]; 291 | } else { 292 | entities = []; 293 | } 294 | } 295 | 296 | // Normalize entities to support both string and object formats 297 | entities = entities 298 | .map((entity) => { 299 | if (typeof entity === 'string') { 300 | // Convert string to object format, but keep it as string if empty 301 | return entity.trim() === '' ? entity : { entity: entity }; 302 | } 303 | return entity; // Already object format 304 | }) 305 | .filter((entity) => { 306 | if (typeof entity === 'string') { 307 | return entity !== ''; // Keep non-empty strings 308 | } 309 | return entity && (entity.entity || entity.entity === ''); // Keep objects with entity property 310 | }); 311 | 312 | // Set defaults and merge config 313 | const newConfig = { 314 | ...TodoSwipeCard.getStubConfig(), 315 | ...config, 316 | entities // Override with our normalized entities array 317 | }; 318 | 319 | // Ensure card_spacing is a valid number 320 | if (newConfig.card_spacing === undefined) { 321 | newConfig.card_spacing = 15; // Default spacing 322 | } else { 323 | newConfig.card_spacing = parseInt(newConfig.card_spacing); 324 | if (isNaN(newConfig.card_spacing) || newConfig.card_spacing < 0) { 325 | newConfig.card_spacing = 15; 326 | } 327 | } 328 | 329 | // Handle card_mod configuration - keep it as card_mod for consistency 330 | if ( 331 | config.card_mod && 332 | typeof config.card_mod === 'object' && 333 | Object.keys(config.card_mod).length > 0 334 | ) { 335 | newConfig.card_mod = config.card_mod; 336 | } else if ( 337 | config.custom_card_mod && 338 | typeof config.custom_card_mod === 'object' && 339 | Object.keys(config.custom_card_mod).length > 0 340 | ) { 341 | newConfig.card_mod = config.custom_card_mod; 342 | } 343 | 344 | // Check if config actually changed 345 | if (!this._hasConfigChanged(newConfig)) { 346 | debugLog('Config unchanged, skipping update'); 347 | return; 348 | } 349 | 350 | // Save the old config for comparison 351 | const oldConfig = this._config; 352 | this._config = newConfig; 353 | this._lastConfig = JSON.parse(JSON.stringify(newConfig)); 354 | 355 | debugLog('Config after processing:', JSON.stringify(this._config)); 356 | 357 | // Apply styles immediately for better perceived performance 358 | this._applyCardModStyles(); 359 | 360 | // If already initialized, determine if we need a full rebuild or just updates 361 | if (this.initialized) { 362 | // Clear any pending config update timer 363 | if (this._configUpdateTimer) { 364 | clearTimeout(this._configUpdateTimer); 365 | } 366 | 367 | // Check if we need a full rebuild 368 | const needsRebuild = this._needsFullRebuild(oldConfig, newConfig); 369 | 370 | if (needsRebuild) { 371 | // Debounce rebuild to prevent excessive DOM manipulation 372 | this._configUpdateTimer = setTimeout(() => { 373 | debugLog('Rebuilding TodoSwipeCard due to significant config change'); 374 | this._build().then(() => { 375 | // Apply transition properties after rebuild 376 | this._applyTransitionProperties(); 377 | this._applyDeleteButtonColor(); 378 | }); 379 | }, 300); // Increased debounce time 380 | } else { 381 | // Just update the specific features without rebuild 382 | this._updateFromConfig(oldConfig); 383 | this._applyTransitionProperties(); 384 | this._applyDeleteButtonColor(); 385 | } 386 | } 387 | } 388 | 389 | /** 390 | * Determine if a full rebuild is needed based on config changes 391 | * @param {Object} oldConfig - Previous configuration 392 | * @param {Object} newConfig - New configuration 393 | * @returns {boolean} True if full rebuild needed 394 | * @private 395 | */ 396 | _needsFullRebuild(oldConfig, newConfig) { 397 | if (!oldConfig) return true; 398 | 399 | // Check for changes that require full rebuild 400 | const entitiesChanged = 401 | JSON.stringify(oldConfig.entities) !== JSON.stringify(newConfig.entities); 402 | const paginationChanged = oldConfig.show_pagination !== newConfig.show_pagination; 403 | const createFieldChanged = oldConfig.show_create !== newConfig.show_create; 404 | const cardModChanged = 405 | JSON.stringify(oldConfig.card_mod) !== JSON.stringify(newConfig.card_mod); 406 | const searchChanged = oldConfig.enable_search !== newConfig.enable_search; 407 | 408 | return ( 409 | entitiesChanged || paginationChanged || createFieldChanged || cardModChanged || searchChanged 410 | ); 411 | } 412 | 413 | /** 414 | * Updates specific card features without a full rebuild 415 | * Optimized version 416 | * @param {Object} oldConfig - Previous configuration 417 | * @private 418 | */ 419 | _updateFromConfig(oldConfig) { 420 | debugLog('Applying minor config updates'); 421 | 422 | // Batch DOM updates using requestAnimationFrame 423 | requestAnimationFrame(() => { 424 | // Update show_completed setting 425 | if (this._config.show_completed !== oldConfig.show_completed) { 426 | this.cards.forEach((card) => { 427 | if (card.element) { 428 | const items = card.element.querySelectorAll('.todo-item.completed'); 429 | items.forEach((item) => { 430 | item.style.display = this._config.show_completed ? '' : 'none'; 431 | }); 432 | } 433 | }); 434 | } 435 | 436 | // Update show_completed_menu setting 437 | if ( 438 | this._config.show_completed_menu !== oldConfig.show_completed_menu || 439 | this._config.show_completed !== oldConfig.show_completed 440 | ) { 441 | this._updateDeleteButtons(); 442 | } 443 | 444 | // Update card spacing 445 | if (this._config.card_spacing !== oldConfig.card_spacing) { 446 | if (this.sliderElement) { 447 | this.sliderElement.style.gap = `${this._config.card_spacing}px`; 448 | this.updateSlider(false); // Update without animation 449 | } 450 | } 451 | 452 | // Apply pagination styles if they changed 453 | if ( 454 | JSON.stringify(this._config.card_mod || this._config.custom_card_mod) !== 455 | JSON.stringify(oldConfig.card_mod || oldConfig.custom_card_mod) 456 | ) { 457 | this._applyCardModStyles(); 458 | if (this.paginationElement) { 459 | this._applyPaginationStyles(); 460 | } 461 | } 462 | }); 463 | } 464 | 465 | /** 466 | * Update delete buttons for all cards 467 | * @private 468 | */ 469 | _updateDeleteButtons() { 470 | this.cards.forEach((card) => { 471 | const slide = card.slide; 472 | if (!slide) return; 473 | 474 | // Remove existing delete buttons 475 | const oldButtons = slide.querySelectorAll('.delete-completed-button'); 476 | oldButtons.forEach((btn) => btn.remove()); 477 | 478 | // Add delete button if configured to show completed items and menu 479 | if (this._config.show_completed && this._config.show_completed_menu) { 480 | // Use the stored entity config from the card object first, then fallback to lookup 481 | const entityConfig = card.entityConfig || this._getEntityConfig(card.entityId); 482 | const deleteButton = this._createDeleteButton(card.entityId, entityConfig); 483 | slide.appendChild(deleteButton); 484 | } 485 | }); 486 | } 487 | 488 | /** 489 | * Create a delete button element 490 | * @param {string} entityId - Entity ID for the todo list 491 | * @param {Object} entityConfig - Entity configuration object 492 | * @returns {HTMLElement} Delete button element 493 | * @private 494 | */ 495 | _createDeleteButton(entityId, entityConfig = null) { 496 | const deleteButton = document.createElement('button'); 497 | deleteButton.className = 'delete-completed-button'; 498 | deleteButton.title = 'Delete completed items'; 499 | deleteButton.innerHTML = ` 500 | 501 | 502 | 503 | `; 504 | 505 | // Auto-adjust position if entity has a title 506 | if (entityConfig && entityConfig.show_title && entityConfig.title) { 507 | // Calculate auto-adjusted position: default 35px + title height (default 40px) 508 | const basePosition = 35; 509 | const titleHeight = 'var(--todo-swipe-card-title-height, 40px)'; 510 | const autoAdjustedTop = `calc(${basePosition}px + ${titleHeight})`; 511 | 512 | // Set the auto-adjustment CSS variable on the button 513 | deleteButton.style.setProperty('--todo-swipe-card-delete-button-auto-top', autoAdjustedTop); 514 | } 515 | 516 | // Apply delete button color if available 517 | if (this._deleteButtonColor) { 518 | deleteButton.style.color = this._deleteButtonColor; 519 | const svg = deleteButton.querySelector('svg'); 520 | if (svg) { 521 | svg.style.fill = this._deleteButtonColor; 522 | } 523 | } 524 | 525 | // Add click handler for the delete button with confirmation dialog 526 | deleteButton.addEventListener('click', (e) => { 527 | e.preventDefault(); 528 | e.stopPropagation(); 529 | 530 | // Check if confirmation is required 531 | if (this._config.delete_confirmation) { 532 | this.dialogManager.showDeleteCompletedConfirmation(entityId); 533 | } else { 534 | // No confirmation needed, delete immediately 535 | deleteCompletedItems(entityId, this._hass); 536 | } 537 | }); 538 | 539 | return deleteButton; 540 | } 541 | 542 | /** 543 | * Enhanced hass setter to set up subscriptions when cards are ready 544 | */ 545 | set hass(hass) { 546 | if (!hass) return; 547 | 548 | const previousHass = this._hass; 549 | this._hass = hass; 550 | 551 | // Initialize subscriptions through subscription manager 552 | this.subscriptionManager.initializeSubscriptions(hass, previousHass); 553 | } 554 | 555 | /** 556 | * Called when the element is connected to the DOM 557 | */ 558 | connectedCallback() { 559 | super.connectedCallback(); 560 | 561 | // Setup event listeners through subscription manager 562 | this.subscriptionManager.setupEventListeners(); 563 | 564 | // Ensure we have a valid config before proceeding 565 | if (!this._config) { 566 | debugLog('TodoSwipeCard connected but no config available'); 567 | return; 568 | } 569 | 570 | if (!this.initialized) { 571 | debugLog('TodoSwipeCard connecting and building'); 572 | this._applyCardModStyles(); 573 | 574 | // Small delay to ensure renderRoot is ready 575 | setTimeout(() => { 576 | this._build(); 577 | }, 0); 578 | } 579 | } 580 | 581 | /** 582 | * Called when the element is disconnected from the DOM 583 | * Improved cleanup for better memory management (simplified from old version) 584 | */ 585 | disconnectedCallback() { 586 | debugLog('TodoSwipeCard disconnecting - performing cleanup'); 587 | 588 | // Clear all timers first to prevent any pending operations 589 | if (this._configUpdateTimer) { 590 | clearTimeout(this._configUpdateTimer); 591 | this._configUpdateTimer = null; 592 | } 593 | 594 | if (this._updateThrottle) { 595 | clearTimeout(this._updateThrottle); 596 | this._updateThrottle = null; 597 | } 598 | 599 | // Clear resize observer 600 | if (this.resizeObserver) { 601 | this.resizeObserver.disconnect(); 602 | this.resizeObserver = null; 603 | } 604 | 605 | // Clean up subscriptions, caches, and event listeners through subscription manager 606 | this.subscriptionManager.cleanup(); 607 | 608 | // Clean up search functionality 609 | cleanupSearchHandlers(this); 610 | 611 | // Properly remove swipe gesture handlers (simplified) 612 | if (this.cardContainer) { 613 | if (this._touchStartHandler) { 614 | this.cardContainer.removeEventListener('touchstart', this._touchStartHandler); 615 | this.cardContainer.removeEventListener('touchmove', this._touchMoveHandler); 616 | this.cardContainer.removeEventListener('touchend', this._touchEndHandler); 617 | this.cardContainer.removeEventListener('touchcancel', this._touchEndHandler); 618 | this.cardContainer.removeEventListener('mousedown', this._mouseDownHandler); 619 | } 620 | 621 | // Clean up window event listeners that might have been added 622 | window.removeEventListener('mousemove', this._mouseMoveHandler); 623 | window.removeEventListener('mouseup', this._mouseUpHandler); 624 | } 625 | 626 | // Mark as not initialized but keep critical references 627 | this.initialized = false; 628 | 629 | // Clear DOM references 630 | this.cards = []; 631 | this.cardContainer = null; 632 | this.sliderElement = null; 633 | this.paginationElement = null; 634 | 635 | // Reset update tracking 636 | this._lastHassUpdate = null; 637 | 638 | // Only clear shadowRoot content if it exists 639 | if (this.shadowRoot) { 640 | this.shadowRoot.innerHTML = ''; 641 | } 642 | 643 | debugLog('TodoSwipeCard cleanup completed'); 644 | } 645 | 646 | /** 647 | * Build the card UI with optimized DOM handling 648 | * Now returns a promise for better async handling 649 | * @private 650 | */ 651 | async _build() { 652 | // Prevent concurrent builds 653 | if (this._buildPromise) { 654 | debugLog('Build already in progress, waiting...'); 655 | return this._buildPromise; 656 | } 657 | 658 | this._buildPromise = this._doBuild(); 659 | 660 | try { 661 | await this._buildPromise; 662 | } finally { 663 | this._buildPromise = null; 664 | } 665 | } 666 | 667 | /** 668 | * Enhanced build method with better initialization 669 | */ 670 | async _doBuild() { 671 | debugLog('Starting build...'); 672 | 673 | // Use document fragment for better performance 674 | const fragment = document.createDocumentFragment(); 675 | const root = this.renderRoot || this.shadowRoot; 676 | if (!root) { 677 | console.error('No render root available'); 678 | return; 679 | } 680 | root.innerHTML = ''; // Clear previous content 681 | 682 | // Add base styles 683 | const style = createBaseStyles(this._config); 684 | fragment.appendChild(style); 685 | 686 | // Re-add the dynamic style element if it exists 687 | if (this._dynamicStyleElement) { 688 | fragment.appendChild(this._dynamicStyleElement); 689 | } 690 | 691 | // Check if we should show the preview (no valid entities configured) 692 | if (!this._hasValidEntities()) { 693 | buildPreview(fragment); 694 | root.appendChild(fragment); 695 | this.initialized = true; 696 | debugLog('Preview build completed'); 697 | return; 698 | } 699 | 700 | // Regular card layout - only proceed if we have valid entities 701 | debugLog('Building regular card layout'); 702 | 703 | // Create container 704 | this.cardContainer = document.createElement('div'); 705 | this.cardContainer.className = 'card-container'; 706 | 707 | // Create slider 708 | this.sliderElement = document.createElement('div'); 709 | this.sliderElement.className = 'slider'; 710 | this.cardContainer.appendChild(this.sliderElement); 711 | fragment.appendChild(this.cardContainer); 712 | 713 | // Append to DOM BEFORE creating cards (important for timing) 714 | root.appendChild(fragment); 715 | 716 | // Initialize cards array 717 | this.cards = []; 718 | 719 | // Create slides for each todo entity (now that DOM is ready) 720 | try { 721 | await this.cardBuilder.createNativeTodoCards(); 722 | } catch (error) { 723 | console.error('Error creating native todo cards:', error); 724 | // Continue with initialization even if card creation fails 725 | } 726 | 727 | // Create pagination if enabled (and more than one card) 728 | if (this._config.show_pagination !== false && this.cards.length > 1) { 729 | createPagination(this); 730 | } else { 731 | this.paginationElement = null; 732 | } 733 | 734 | // Initial positioning requires element dimensions, wait for next frame 735 | requestAnimationFrame(() => { 736 | if (!this.cardContainer) { 737 | return; 738 | } 739 | 740 | this.slideWidth = this.cardContainer.offsetWidth; 741 | // Ensure currentIndex is valid before updating slider 742 | this.currentIndex = Math.max(0, Math.min(this.currentIndex, this.cards.length - 1)); 743 | 744 | // Apply border radius to all slides 745 | const cardBorderRadius = getComputedStyle(this.cardContainer).borderRadius; 746 | this.cards.forEach((cardData) => { 747 | if (cardData.slide) { 748 | cardData.slide.style.borderRadius = cardBorderRadius; 749 | } 750 | }); 751 | 752 | this.updateSlider(false); // Update without animation initially 753 | 754 | // Setup resize observer only after initial layout 755 | this._setupResizeObserver(); 756 | 757 | // IMPORTANT: Force initial update of all cards after DOM is ready 758 | debugLog('Forcing initial update of all cards'); 759 | this.cards.forEach((card, index) => { 760 | if (card && card.element && card.entityId) { 761 | debugLog(`Force updating card ${index} for entity ${card.entityId}`); 762 | // Use setTimeout to ensure DOM is fully rendered 763 | setTimeout(() => { 764 | this.cardBuilder.updateNativeTodoCard(card.element, card.entityId); 765 | }, 50 * index); // Stagger updates slightly 766 | } 767 | }); 768 | }); 769 | 770 | // Add swipe detection only if more than one card 771 | if (this.cards.length > 1) { 772 | addSwipeGestures(this); 773 | } 774 | 775 | // Apply transition properties 776 | setTimeout(() => { 777 | this._applyTransitionProperties(); 778 | }, 200); 779 | 780 | // Mark as initialized AFTER build completes 781 | this.initialized = true; 782 | debugLog('Regular card build completed.'); 783 | } 784 | 785 | /** 786 | * Add a new todo item 787 | * @param {string} entityId - Entity ID 788 | * @param {string} summary - Item summary 789 | * @private 790 | */ 791 | _addTodoItem(entityId, summary) { 792 | addTodoItem(entityId, summary, this._hass); 793 | } 794 | 795 | /** 796 | * Toggle todo item completion status 797 | * @param {string} entityId - Entity ID 798 | * @param {Object} item - Todo item 799 | * @param {boolean} completed - New completion status 800 | * @private 801 | */ 802 | _toggleTodoItem(entityId, item, completed) { 803 | toggleTodoItem(entityId, item, completed, this._hass); 804 | 805 | // Clear search when unchecking an item (if enabled) 806 | if (!completed && this._config.clear_search_on_uncheck && this._config.enable_search) { 807 | const searchText = this._searchStates.get(entityId); 808 | if (searchText && searchText.trim() !== '') { 809 | debugLog(`Clearing search after unchecking item "${item.summary}"`); 810 | this._searchStates.delete(entityId); 811 | this._currentSearchText = ''; 812 | 813 | // Find and clear the input field, then update the card 814 | const card = this.cards.find((c) => c.entityId === entityId); 815 | if (card && card.element) { 816 | let inputElement; 817 | if (card.element.classList.contains('todo-card-with-title-wrapper')) { 818 | inputElement = card.element.querySelector('.native-todo-card .add-textfield input'); 819 | } else { 820 | inputElement = card.element.querySelector('.add-textfield input'); 821 | } 822 | if (inputElement) { 823 | inputElement.value = ''; 824 | } 825 | 826 | // Update the card to refresh the display 827 | this.cardBuilder.updateNativeTodoCard(card.element, entityId); 828 | } 829 | } 830 | } 831 | } 832 | 833 | /** 834 | * Edit todo item using the dialog manager 835 | * @param {string} entityId - Entity ID 836 | * @param {Object} item - Todo item 837 | * @private 838 | */ 839 | _editTodoItem(entityId, item) { 840 | this.dialogManager.editTodoItem(entityId, item); 841 | } 842 | 843 | /** 844 | * Setup resize observer with improved debounce 845 | * @private 846 | */ 847 | _setupResizeObserver() { 848 | if (this.resizeObserver) { 849 | this.resizeObserver.disconnect(); 850 | } 851 | 852 | let resizeTimeout; 853 | this.resizeObserver = new ResizeObserver(() => { 854 | // Clear existing timeout 855 | if (resizeTimeout) clearTimeout(resizeTimeout); 856 | 857 | // Debounce resize handling 858 | resizeTimeout = setTimeout(() => { 859 | if (!this.cardContainer) return; 860 | 861 | const newWidth = this.cardContainer.offsetWidth; 862 | // Only update if width actually changed significantly 863 | if (newWidth > 0 && Math.abs(newWidth - this.slideWidth) > 1) { 864 | debugLog('Resizing slider...'); 865 | this.slideWidth = newWidth; 866 | 867 | // Batch DOM updates 868 | requestAnimationFrame(() => { 869 | // Reapply border radius when resizing 870 | const cardBorderRadius = getComputedStyle(this.cardContainer).borderRadius; 871 | this.cards.forEach((cardData) => { 872 | if (cardData.slide) { 873 | cardData.slide.style.borderRadius = cardBorderRadius; 874 | } 875 | }); 876 | 877 | this.updateSlider(false); // Update without animation on resize 878 | }); 879 | } 880 | }, 200); // Increased debounce time 881 | }); 882 | 883 | if (this.cardContainer) { 884 | this.resizeObserver.observe(this.cardContainer); 885 | } 886 | } 887 | 888 | /** 889 | * Navigate to a specific slide 890 | * @param {number} index - The slide index to go to 891 | */ 892 | goToSlide(index) { 893 | if (!this.cards || this.cards.length === 0 || !this.initialized) return; 894 | 895 | index = Math.max(0, Math.min(index, this.cards.length - 1)); 896 | 897 | if (index === this.currentIndex) return; 898 | 899 | this.currentIndex = index; 900 | this.updateSlider(); 901 | } 902 | 903 | /** 904 | * Update slider position and pagination 905 | * @param {boolean} animate - Whether to animate the transition 906 | */ 907 | updateSlider(animate = true) { 908 | if (!this.sliderElement || !this.slideWidth || this.cards.length === 0 || !this.initialized) { 909 | return; 910 | } 911 | 912 | // Batch all DOM updates 913 | requestAnimationFrame(() => { 914 | // Re-check if elements still exist inside requestAnimationFrame 915 | if (!this.sliderElement || !this.cardContainer || !this.initialized) { 916 | return; 917 | } 918 | 919 | // Use stored transition values if available, otherwise default 920 | const transitionSpeed = this._transitionSpeed || '0.3s'; 921 | const transitionEasing = this._transitionEasing || 'ease-out'; 922 | 923 | // Set transition based on animate parameter 924 | this.sliderElement.style.transition = animate 925 | ? `transform ${transitionSpeed} ${transitionEasing}` 926 | : 'none'; 927 | 928 | // Get card spacing from config 929 | const cardSpacing = this._config.card_spacing || 0; 930 | 931 | // Update slider gap for spacing 932 | this.sliderElement.style.gap = `${cardSpacing}px`; 933 | 934 | // Calculate transform using pixel values including spacing 935 | const translateX = this.currentIndex * (this.slideWidth + cardSpacing); 936 | this.sliderElement.style.transform = `translateX(-${translateX}px)`; 937 | 938 | // Get the border radius from the container and apply to all slides 939 | const cardBorderRadius = getComputedStyle(this.cardContainer).borderRadius; 940 | this.cards.forEach((card) => { 941 | if (card.slide) { 942 | card.slide.style.marginRight = '0px'; // Ensure margins are reset 943 | card.slide.style.borderRadius = cardBorderRadius; // Apply border radius to slides 944 | } 945 | }); 946 | 947 | // Update pagination 948 | updatePaginationDots(this); 949 | 950 | // Handle search state when sliding to a new card 951 | if (this._config.enable_search && this.cards[this.currentIndex]) { 952 | // Don't restore search state - always start with clean slate 953 | } 954 | }); 955 | } 956 | 957 | /** 958 | * Get card size for Home Assistant layout system 959 | * @returns {number} Card size 960 | */ 961 | getCardSize() { 962 | return 3; 963 | } 964 | } 965 | --------------------------------------------------------------------------------