feat(web): add Ctrl+K command palette search modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,37 @@
|
|||||||
@using TaskTracker.Api.Pages
|
|
||||||
@using TaskTracker.Core.Enums
|
@using TaskTracker.Core.Enums
|
||||||
@model List<TaskTracker.Core.Entities.WorkTask>
|
@model List<TaskTracker.Core.Entities.WorkTask>
|
||||||
|
@{
|
||||||
|
var statusColors = new Dictionary<WorkTaskStatus, string>
|
||||||
|
{
|
||||||
|
[WorkTaskStatus.Pending] = "#64748b",
|
||||||
|
[WorkTaskStatus.Active] = "#3b82f6",
|
||||||
|
[WorkTaskStatus.Paused] = "#eab308",
|
||||||
|
[WorkTaskStatus.Completed] = "#22c55e",
|
||||||
|
[WorkTaskStatus.Abandoned] = "#ef4444",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@* Placeholder — full implementation in Task 6 (Search Modal) *@
|
@if (Model.Count == 0)
|
||||||
<div class="search-results">
|
{
|
||||||
@if (Model.Count == 0)
|
<div class="search-empty">No results found</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@for (var i = 0; i < Model.Count; i++)
|
||||||
{
|
{
|
||||||
<div style="padding: 16px; text-align: center; color: var(--color-text-secondary); font-size: 13px;">
|
var task = Model[i];
|
||||||
No results found
|
var color = statusColors.GetValueOrDefault(task.Status, "#64748b");
|
||||||
</div>
|
<div class="search-result @(i == 0 ? "search-result--selected" : "")"
|
||||||
}
|
data-task-id="@task.Id"
|
||||||
else
|
onclick="selectSearchResult(@task.Id)"
|
||||||
{
|
onmouseenter="highlightResult(this)">
|
||||||
@foreach (var task in Model)
|
<span class="search-result-dot" style="background: @color"></span>
|
||||||
{
|
<span class="search-result-title">@task.Title</span>
|
||||||
<div class="search-result-item"
|
|
||||||
hx-get="/board?handler=TaskDetail&id=@task.Id"
|
|
||||||
hx-target="#detail-panel"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
style="padding: 10px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--color-border);">
|
|
||||||
<span class="badge badge--@task.Status.ToString().ToLower()" style="flex-shrink: 0;">
|
|
||||||
@task.Status
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 14px; color: var(--color-text-primary);">@task.Title</span>
|
|
||||||
@if (!string.IsNullOrEmpty(task.Category))
|
@if (!string.IsNullOrEmpty(task.Category))
|
||||||
{
|
{
|
||||||
<span style="margin-left: auto; font-size: 11px; color: var(--color-text-tertiary);">@task.Category</span>
|
<span class="search-result-category">@task.Category</span>
|
||||||
}
|
}
|
||||||
|
<span class="search-result-arrow">→</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button type="button" class="btn btn-search" id="search-trigger">
|
<button type="button" class="btn btn-search" id="search-trigger" onclick="openSearch()">
|
||||||
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
@@ -50,7 +50,29 @@
|
|||||||
@RenderBody()
|
@RenderBody()
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="search-modal"></div>
|
<div id="search-modal" class="search-backdrop" onclick="if(event.target===this)closeSearch()">
|
||||||
|
<div class="search-modal">
|
||||||
|
<div class="search-input-row">
|
||||||
|
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Search tasks..."
|
||||||
|
autocomplete="off"
|
||||||
|
hx-get="/board?handler=Search"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="input changed delay:200ms, search"
|
||||||
|
hx-include="this"
|
||||||
|
name="q" />
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="search-results">
|
||||||
|
<!-- Results loaded via htmx -->
|
||||||
|
</div>
|
||||||
|
<div class="search-footer">
|
||||||
|
<span><kbd>↑↓</kbd> navigate</span>
|
||||||
|
<span><kbd>↵</kbd> open</span>
|
||||||
|
<span><kbd>esc</kbd> close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="detail-panel"></div>
|
<div id="detail-panel"></div>
|
||||||
|
|
||||||
<script src="~/lib/htmx.min.js"></script>
|
<script src="~/lib/htmx.min.js"></script>
|
||||||
|
|||||||
@@ -144,8 +144,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Global Escape key handler for closing the detail panel
|
// Global Escape key handler for closing the detail panel
|
||||||
|
// (Search modal Escape is handled separately with stopPropagation)
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
// Don't close detail panel if search modal handled Escape
|
||||||
|
var searchModal = document.getElementById('search-modal');
|
||||||
|
if (searchModal && searchModal.classList.contains('search-backdrop--open')) return;
|
||||||
|
|
||||||
var panel = document.querySelector('.detail-panel--open');
|
var panel = document.querySelector('.detail-panel--open');
|
||||||
if (panel) {
|
if (panel) {
|
||||||
closeDetailPanel();
|
closeDetailPanel();
|
||||||
@@ -154,4 +159,105 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Search Modal (Ctrl+K)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
var searchSelectedIndex = 0;
|
||||||
|
|
||||||
|
window.openSearch = function() {
|
||||||
|
var modal = document.getElementById('search-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add('search-backdrop--open');
|
||||||
|
var input = document.getElementById('search-input');
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
// Trigger initial load (show recent tasks when empty)
|
||||||
|
htmx.trigger(input, 'search');
|
||||||
|
}
|
||||||
|
searchSelectedIndex = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeSearch = function() {
|
||||||
|
var modal = document.getElementById('search-modal');
|
||||||
|
if (modal) modal.classList.remove('search-backdrop--open');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.selectSearchResult = function(taskId) {
|
||||||
|
closeSearch();
|
||||||
|
// Load the task detail panel
|
||||||
|
htmx.ajax('GET', '/board?handler=TaskDetail&id=' + taskId, {
|
||||||
|
target: '#detail-panel',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.highlightResult = function(el) {
|
||||||
|
var results = document.querySelectorAll('.search-result');
|
||||||
|
results.forEach(function(r) { r.classList.remove('search-result--selected'); });
|
||||||
|
el.classList.add('search-result--selected');
|
||||||
|
// Update index
|
||||||
|
searchSelectedIndex = Array.from(results).indexOf(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard navigation in search
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
var modal = document.getElementById('search-modal');
|
||||||
|
if (!modal || !modal.classList.contains('search-backdrop--open')) {
|
||||||
|
// Ctrl+K / Cmd+K to open
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
openSearch();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal is open
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = document.querySelectorAll('.search-result');
|
||||||
|
if (results.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchSelectedIndex = Math.min(searchSelectedIndex + 1, results.length - 1);
|
||||||
|
updateSearchSelection(results);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchSelectedIndex = Math.max(searchSelectedIndex - 1, 0);
|
||||||
|
updateSearchSelection(results);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
var selected = results[searchSelectedIndex];
|
||||||
|
if (selected) {
|
||||||
|
var taskId = selected.dataset.taskId;
|
||||||
|
selectSearchResult(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSearchSelection(results) {
|
||||||
|
results.forEach(function(r, i) {
|
||||||
|
if (i === searchSelectedIndex) {
|
||||||
|
r.classList.add('search-result--selected');
|
||||||
|
r.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else {
|
||||||
|
r.classList.remove('search-result--selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset selection when search results are updated
|
||||||
|
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
if (evt.detail.target && evt.detail.target.id === 'search-results') {
|
||||||
|
searchSelectedIndex = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user