From e52d38793bf6f002a32d5f74f35b86ac4860e68a Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 19:16:50 +0000 Subject: [PATCH] feat: US-006 - Frontend: Icon picker as compact dropdown --- dashboard/habits.html | 189 ++++++++++++++++++++-- dashboard/tests/test_habits_frontend.py | 203 +++++++++++++++++++++++- 2 files changed, 380 insertions(+), 12 deletions(-) diff --git a/dashboard/habits.html b/dashboard/habits.html index ed833a6..8e8b8b1 100644 --- a/dashboard/habits.html +++ b/dashboard/habits.html @@ -626,7 +626,84 @@ box-shadow: 0 0 0 2px var(--bg-surface), 0 0 0 4px var(--accent); } - /* Icon picker */ + /* Icon picker dropdown */ + .icon-picker-dropdown { + position: relative; + } + + .icon-picker-trigger { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-base); + min-height: 44px; + } + + .icon-picker-trigger:hover { + background: var(--bg-hover); + border-color: var(--accent); + } + + .icon-picker-trigger svg { + width: 20px; + height: 20px; + color: var(--text-primary); + } + + .icon-picker-trigger span { + flex: 1; + text-align: left; + color: var(--text-primary); + } + + .trigger-chevron { + transition: transform var(--transition-base); + } + + .icon-picker-trigger.open .trigger-chevron { + transform: rotate(180deg); + } + + .icon-picker-content { + position: absolute; + top: calc(100% + var(--space-1)); + left: 0; + right: 0; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 100; + display: none; + max-height: 300px; + overflow: hidden; + } + + .icon-picker-content.visible { + display: block; + } + + .icon-search-input { + width: 100%; + padding: var(--space-2); + border: none; + border-bottom: 1px solid var(--border); + background: var(--bg-base); + color: var(--text-primary); + font-size: var(--text-sm); + outline: none; + } + + .icon-search-input:focus { + border-bottom-color: var(--accent); + } + .icon-picker-grid { display: grid; grid-template-columns: repeat(6, 1fr); @@ -634,9 +711,6 @@ max-height: 250px; overflow-y: auto; padding: var(--space-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-base); } .icon-option { @@ -649,6 +723,7 @@ cursor: pointer; transition: all var(--transition-base); background: var(--bg-surface); + min-height: 44px; } .icon-option:hover { @@ -1125,7 +1200,17 @@
-
+
+ +
+ +
+
+
@@ -1855,6 +1940,69 @@ ` ).join(''); + + // Update trigger button with selected icon + const selectedIconDisplay = document.getElementById('selectedIconDisplay'); + selectedIconDisplay.setAttribute('data-lucide', selectedIcon); + + lucide.createIcons(); + } + + // Toggle icon picker dropdown + function toggleIconPicker() { + const content = document.getElementById('iconPickerContent'); + const trigger = document.getElementById('iconPickerTrigger'); + const isOpen = content.classList.contains('visible'); + + if (isOpen) { + closeIconPicker(); + } else { + openIconPicker(); + } + } + + // Open icon picker dropdown + function openIconPicker() { + const content = document.getElementById('iconPickerContent'); + const trigger = document.getElementById('iconPickerTrigger'); + const search = document.getElementById('iconSearch'); + + content.classList.add('visible'); + trigger.classList.add('open'); + + // Reset search + search.value = ''; + filterIcons(); + + // Focus search input + setTimeout(() => search.focus(), 50); + } + + // Close icon picker dropdown + function closeIconPicker() { + const content = document.getElementById('iconPickerContent'); + const trigger = document.getElementById('iconPickerTrigger'); + + content.classList.remove('visible'); + trigger.classList.remove('open'); + } + + // Filter icons based on search query + function filterIcons() { + const searchQuery = document.getElementById('iconSearch').value.toLowerCase(); + const iconPickerContainer = document.getElementById('iconPicker'); + + const filteredIcons = commonIcons.filter(icon => + icon.toLowerCase().includes(searchQuery) + ); + + iconPickerContainer.innerHTML = filteredIcons.map(icon => + `
+ +
` + ).join(''); + lucide.createIcons(); } @@ -1864,13 +2012,20 @@ // Update icon options const iconOptions = document.querySelectorAll('.icon-option'); - iconOptions.forEach((option, index) => { - if (commonIcons[index] === icon) { - option.classList.add('selected'); - } else { - option.classList.remove('selected'); - } + iconOptions.forEach(option => { + option.classList.remove('selected'); }); + + // Add selected class to clicked option + event.target.closest('.icon-option')?.classList.add('selected'); + + // Update trigger button display + const selectedIconDisplay = document.getElementById('selectedIconDisplay'); + selectedIconDisplay.setAttribute('data-lucide', icon); + lucide.createIcons(); + + // Close dropdown after selection + closeIconPicker(); } // Update frequency params based on selected type @@ -2472,6 +2627,18 @@ } }); + // Click outside icon picker to close + document.addEventListener('click', (e) => { + const dropdown = document.querySelector('.icon-picker-dropdown'); + const content = document.getElementById('iconPickerContent'); + + if (content && content.classList.contains('visible')) { + if (!dropdown.contains(e.target)) { + closeIconPicker(); + } + } + }); + // Restore collapsed/expanded state from localStorage restoreWeeklySummaryState(); diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py index 4ce2d50..c344885 100644 --- a/dashboard/tests/test_habits_frontend.py +++ b/dashboard/tests/test_habits_frontend.py @@ -1767,9 +1767,23 @@ def run_all_tests(): test_css_transition_300ms, test_height_constraint_collapsed, test_typecheck_us005, + # US-006 tests + test_icon_picker_is_dropdown, + test_icon_picker_has_search_input, + test_icon_picker_max_height_300px, + test_icon_picker_toggle_functions, + test_icon_picker_visible_class_toggle, + test_icon_picker_filter_function, + test_icon_picker_closes_on_selection, + test_icon_picker_click_outside_closes, + test_icon_picker_updates_trigger_display, + test_icon_picker_css_dropdown_styles, + test_icon_picker_chevron_rotation, + test_icon_picker_search_autofocus, + test_typecheck_us006, ] - print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-005, US-006 through US-014...\n") + print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-004, US-005, US-006 through US-014...\n") failed = [] for test in tests: @@ -2530,3 +2544,190 @@ def test_typecheck_us005(): if __name__ == '__main__': run_all_tests() + +# US-006: Frontend: Icon picker as compact dropdown +def test_icon_picker_is_dropdown(): + """Test 151: Icon picker renders as dropdown with trigger button""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for dropdown structure + assert 'icon-picker-dropdown' in html, "Icon picker should be a dropdown" + assert 'icon-picker-trigger' in html, "Dropdown should have trigger button" + assert 'icon-picker-content' in html, "Dropdown should have content container" + + # Check trigger button has icon display and chevron + assert 'selectedIconDisplay' in html, "Trigger should display selected icon" + assert 'iconPickerChevron' in html, "Trigger should have chevron icon" + assert 'chevron-down' in html, "Chevron should be down arrow" + + print("✓ Test 151 passed: Icon picker is dropdown structure") + +def test_icon_picker_has_search_input(): + """Test 152: Dropdown has search input for filtering icons""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for search input + assert 'icon-search-input' in html, "Dropdown should have search input" + assert 'id="iconSearch"' in html, "Search input should have iconSearch id" + assert 'placeholder="Search icons..."' in html, "Search input should have placeholder" + assert 'oninput="filterIcons()"' in html, "Search input should trigger filterIcons()" + + print("✓ Test 152 passed: Dropdown has search input") + +def test_icon_picker_max_height_300px(): + """Test 153: Dropdown content has max-height 300px with scroll""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for max-height in CSS + assert 'max-height: 300px' in html, "Icon picker content should have max-height 300px" + assert 'overflow-y: auto' in html or 'overflow: hidden' in html, "Should have overflow handling" + + print("✓ Test 153 passed: Max-height 300px with scroll") + +def test_icon_picker_toggle_functions(): + """Test 154: Toggle functions exist for opening/closing dropdown""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for toggle functions + assert 'function toggleIconPicker()' in html, "toggleIconPicker function should exist" + assert 'function openIconPicker()' in html, "openIconPicker function should exist" + assert 'function closeIconPicker()' in html, "closeIconPicker function should exist" + assert 'onclick="toggleIconPicker()"' in html, "Trigger should call toggleIconPicker" + + print("✓ Test 154 passed: Toggle functions exist") + +def test_icon_picker_visible_class_toggle(): + """Test 155: Dropdown uses visible class to show/hide content""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for visible class handling in CSS + assert '.icon-picker-content.visible' in html, "CSS should have visible class rule" + assert 'display: block' in html, "Visible class should display content" + assert 'display: none' in html, "Hidden state should have display none" + + # Check for visible class toggling in JS + assert "classList.add('visible')" in html, "Should add visible class to open" + assert "classList.remove('visible')" in html, "Should remove visible class to close" + + print("✓ Test 155 passed: Visible class toggle logic") + +def test_icon_picker_filter_function(): + """Test 156: filterIcons function filters icons based on search""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for filterIcons function + assert 'function filterIcons()' in html, "filterIcons function should exist" + + # Check filter logic + search_section = html[html.find('function filterIcons()'):html.find('function filterIcons()') + 1000] + assert 'toLowerCase()' in search_section, "Should use case-insensitive search" + assert 'filter' in search_section, "Should use filter method" + assert 'includes' in search_section, "Should check if icon name includes query" + + print("✓ Test 156 passed: filterIcons function implementation") + +def test_icon_picker_closes_on_selection(): + """Test 157: Selecting an icon closes the dropdown""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check that selectIcon calls closeIconPicker + select_icon_section = html[html.find('function selectIcon('):html.find('function selectIcon(') + 1500] + assert 'closeIconPicker()' in select_icon_section, "selectIcon should call closeIconPicker" + + print("✓ Test 157 passed: Selection closes dropdown") + +def test_icon_picker_click_outside_closes(): + """Test 158: Clicking outside dropdown closes it""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for click outside listener comment and handler + assert "Click outside icon picker to close" in html, "Should have icon picker click-outside comment" + + # Find the icon picker click handler (after the comment) + click_handler_start = html.find("Click outside icon picker to close") + if click_handler_start > 0: + click_handler = html[click_handler_start:click_handler_start + 500] + assert 'icon-picker-dropdown' in click_handler, "Should reference icon-picker-dropdown" + assert 'contains' in click_handler, "Should check if click is inside dropdown" + assert 'closeIconPicker()' in click_handler, "Should close if clicked outside" + + print("✓ Test 158 passed: Click outside closes dropdown") + +def test_icon_picker_updates_trigger_display(): + """Test 159: Selected icon updates trigger button display""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check that selectIcon updates the trigger display + select_icon_section = html[html.find('function selectIcon('):html.find('function selectIcon(') + 1500] + assert 'selectedIconDisplay' in select_icon_section, "Should update selectedIconDisplay element" + assert 'setAttribute' in select_icon_section, "Should set icon attribute" + assert 'data-lucide' in select_icon_section, "Should update data-lucide attribute" + assert 'lucide.createIcons()' in select_icon_section, "Should refresh Lucide icons" + + print("✓ Test 159 passed: Trigger display updates on selection") + +def test_icon_picker_css_dropdown_styles(): + """Test 160: CSS includes dropdown-specific styles""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for dropdown-specific CSS + assert '.icon-picker-dropdown' in html, "Should have dropdown container styles" + assert '.icon-picker-trigger' in html, "Should have trigger button styles" + assert '.icon-picker-content' in html, "Should have content container styles" + assert 'position: absolute' in html, "Content should be absolutely positioned" + assert 'z-index: 100' in html, "Content should have high z-index" + + # Check for hover states + assert '.icon-picker-trigger:hover' in html, "Should have trigger hover state" + + print("✓ Test 160 passed: Dropdown CSS styles present") + +def test_icon_picker_chevron_rotation(): + """Test 161: Chevron rotates when dropdown opens""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check for chevron rotation in CSS + assert 'trigger-chevron' in html, "Chevron should have trigger-chevron class" + assert 'transform: rotate(180deg)' in html, "Chevron should rotate 180deg when open" + assert '.open .trigger-chevron' in html or '.icon-picker-trigger.open .trigger-chevron' in html, "Should rotate when trigger has open class" + + # Check that open class is toggled + assert "classList.add('open')" in html, "Should add open class" + assert "classList.remove('open')" in html, "Should remove open class" + + print("✓ Test 161 passed: Chevron rotation logic") + +def test_icon_picker_search_autofocus(): + """Test 162: Search input gets focus when dropdown opens""" + habits_path = Path(__file__).parent.parent / 'habits.html' + html = habits_path.read_text() + + # Check that openIconPicker focuses the search input + open_icon_picker = html[html.find('function openIconPicker()'):html.find('function openIconPicker()') + 800] + assert 'focus()' in open_icon_picker, "Should focus search input when opening" + assert 'setTimeout' in open_icon_picker, "Should use setTimeout for focus after animation" + + print("✓ Test 162 passed: Search input autofocus") + +def test_typecheck_us006(): + """Test 163: Typecheck passes""" + result = subprocess.run( + ['python3', '-m', 'py_compile', 'dashboard/api.py', 'dashboard/habits_helpers.py'], + cwd='/home/moltbot/clawd', + capture_output=True, + text=True + ) + assert result.returncode == 0, f"Typecheck failed: {result.stderr}" + print("✓ Test 163 passed: Typecheck successful") +