feat: US-006 - Frontend: Icon picker as compact dropdown

This commit is contained in:
Echo
2026-02-10 19:16:50 +00:00
parent 9a899f94fd
commit e52d38793b
2 changed files with 380 additions and 12 deletions

View File

@@ -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 @@
<!-- Icon -->
<div class="form-field">
<label class="form-label">Icon</label>
<div class="icon-picker-grid" id="iconPicker"></div>
<div class="icon-picker-dropdown">
<button type="button" class="icon-picker-trigger" id="iconPickerTrigger" onclick="toggleIconPicker()">
<i data-lucide="smile" id="selectedIconDisplay"></i>
<span>Select Icon</span>
<i data-lucide="chevron-down" class="trigger-chevron" id="iconPickerChevron"></i>
</button>
<div class="icon-picker-content" id="iconPickerContent">
<input type="text" id="iconSearch" class="icon-search-input" placeholder="Search icons..." oninput="filterIcons()">
<div class="icon-picker-grid" id="iconPicker"></div>
</div>
</div>
</div>
<!-- Priority -->
@@ -1855,6 +1940,69 @@
<i data-lucide="${icon}"></i>
</div>`
).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 =>
`<div class="icon-option ${icon === selectedIcon ? 'selected' : ''}"
onclick="selectIcon('${icon}')">
<i data-lucide="${icon}"></i>
</div>`
).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();

View File

@@ -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")