feat: US-006 - Frontend: Icon picker as compact dropdown
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user