feat(ui): FAB toggle, filter bar layout fix, and filter @mentions

- Add collapsible FAB container with chevron toggle button to
  temporarily hide floating action buttons that overlap messages
- Make filter bar push messages down instead of overlaying the first
  matched message (CSS sibling selector adds padding-top)
- Add @mentions autocomplete to filter search bar - typing @ shows
  contact list dropdown, selecting inserts plain name (not @[] format)
  so all messages from/mentioning that user are found

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-02-23 08:47:00 +01:00
parent 000c4f6884
commit 6310c41934
3 changed files with 293 additions and 7 deletions
+75 -1
View File
@@ -870,6 +870,48 @@ main {
transition: transform 0.2s ease;
}
/* FAB toggle button (smaller, semi-transparent) */
.fab-toggle {
width: 32px;
height: 32px;
background: rgba(108, 117, 125, 0.6);
color: white;
font-size: 0.85rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.fab-toggle:hover {
background: rgba(108, 117, 125, 0.9);
}
/* Collapsed state - hide all FABs except toggle */
.fab-container.collapsed .fab:not(.fab-toggle) {
opacity: 0;
pointer-events: none;
transform: scale(0);
height: 0;
width: 0;
margin: 0;
overflow: hidden;
}
.fab-container.collapsed {
gap: 0;
}
/* Smooth transitions for collapse */
.fab-container .fab:not(.fab-toggle) {
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease, height 0.2s ease, width 0.2s ease;
}
.fab-toggle i {
transition: transform 0.2s ease;
}
.fab-container.collapsed .fab-toggle i {
transform: rotate(180deg);
}
/* Mobile optimization */
@media (max-width: 768px) {
.fab-container {
@@ -883,6 +925,12 @@ main {
height: 48px;
font-size: 1.25rem;
}
.fab-toggle {
width: 28px;
height: 28px;
font-size: 0.75rem;
}
}
/* =============================================================================
@@ -1220,6 +1268,15 @@ main {
visibility: visible;
}
/* Push messages container down when filter bar is visible */
.messages-container {
transition: padding-top 0.3s ease;
}
.filter-bar.visible ~ .messages-container {
padding-top: calc(1rem + 52px) !important; /* 52px ≈ filter bar height (0.75rem*2 padding + 36px input + border) */
}
/* Filter bar inner layout */
.filter-bar-inner {
display: flex;
@@ -1228,7 +1285,6 @@ main {
}
.filter-bar-input {
flex: 1;
border-radius: 0.375rem;
border: 1px solid #ced4da;
padding: 0.5rem 0.75rem;
@@ -1307,6 +1363,24 @@ main {
display: block;
}
/* Filter input wrapper for mentions popup positioning */
.filter-input-wrapper {
flex: 1;
position: relative;
}
.filter-input-wrapper .filter-bar-input {
width: 100%;
}
/* Filter mentions popup - appears below input (not above like message input) */
.filter-mentions-popup {
bottom: auto !important;
top: 100% !important;
margin-top: 0.25rem;
margin-bottom: 0;
}
/* Mobile responsive filter bar */
@media (max-width: 576px) {
.filter-bar {
+207 -4
View File
@@ -324,6 +324,9 @@ document.addEventListener('DOMContentLoaded', async function() {
// Initialize filter functionality
initializeFilter();
// Initialize FAB toggle
initializeFabToggle();
// Setup auto-refresh immediately after messages are displayed
// Don't wait for geo cache - it's not needed for auto-refresh
setupAutoRefresh();
@@ -2808,6 +2811,22 @@ async function loadContactsForMentions() {
}
}
// =============================================================================
// FAB Toggle (Collapse/Expand)
// =============================================================================
function initializeFabToggle() {
const toggle = document.getElementById('fabToggle');
const container = document.getElementById('fabContainer');
if (!toggle || !container) return;
toggle.addEventListener('click', () => {
container.classList.toggle('collapsed');
const isCollapsed = container.classList.contains('collapsed');
toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons';
});
}
// =============================================================================
// Chat Filter Functionality
// =============================================================================
@@ -2834,9 +2853,14 @@ function initializeFilter() {
openFilterBar();
});
// Filter as user types (debounced)
// Filter as user types (debounced) - also check for @mentions
let filterTimeout = null;
filterInput.addEventListener('input', () => {
// Check for @mention trigger
if (handleFilterMentionInput(filterInput)) {
return; // Don't apply filter while picking a mention
}
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
applyFilter(filterInput.value);
@@ -2847,6 +2871,7 @@ function initializeFilter() {
filterClearBtn.addEventListener('click', () => {
filterInput.value = '';
applyFilter('');
hideFilterMentionsPopup();
filterInput.focus();
});
@@ -2855,11 +2880,30 @@ function initializeFilter() {
closeFilterBar();
});
// Keyboard shortcuts
// Keyboard shortcuts (with mentions navigation support)
filterInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeFilterBar();
// If filter mentions popup is active, handle navigation
if (filterMentionActive) {
if (handleFilterMentionKeydown(e)) return;
}
if (e.key === 'Escape') {
if (filterMentionActive) {
hideFilterMentionsPopup();
e.preventDefault();
} else {
closeFilterBar();
}
}
});
// Close filter mentions on blur
filterInput.addEventListener('blur', () => {
setTimeout(hideFilterMentionsPopup, 200);
});
// Preload contacts when filter bar is focused
filterInput.addEventListener('focus', () => {
loadContactsForMentions();
});
// Global keyboard shortcut: Ctrl+F to open filter
@@ -2896,6 +2940,7 @@ function closeFilterBar() {
filterBar.classList.remove('visible');
filterActive = false;
hideFilterMentionsPopup();
// Reset filter
filterInput.value = '';
@@ -3026,6 +3071,164 @@ function getMessageId(messageEl) {
return 'msg_' + children.indexOf(messageEl);
}
// =============================================================================
// Filter Mentions Autocomplete
// =============================================================================
let filterMentionActive = false;
let filterMentionStartPos = -1;
let filterMentionSelectedIndex = 0;
/**
* Handle input in filter bar to detect @mention trigger
* @returns {boolean} true if in mention mode (caller should skip filter apply)
*/
function handleFilterMentionInput(input) {
const cursorPos = input.selectionStart;
const text = input.value;
const textBeforeCursor = text.substring(0, cursorPos);
const lastAtPos = textBeforeCursor.lastIndexOf('@');
if (lastAtPos >= 0) {
const textAfterAt = textBeforeCursor.substring(lastAtPos + 1);
// No whitespace after @ means we're typing a mention
if (!/[\s\n]/.test(textAfterAt)) {
filterMentionStartPos = lastAtPos;
filterMentionActive = true;
showFilterMentionsPopup(textAfterAt);
return true;
}
}
if (filterMentionActive) {
hideFilterMentionsPopup();
}
return false;
}
/**
* Handle keyboard navigation in filter mentions popup
* @returns {boolean} true if the key was handled
*/
function handleFilterMentionKeydown(e) {
const popup = document.getElementById('filterMentionsPopup');
const items = popup.querySelectorAll('.mention-item');
if (items.length === 0) return false;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
filterMentionSelectedIndex = Math.min(filterMentionSelectedIndex + 1, items.length - 1);
updateFilterMentionHighlight(items);
return true;
case 'ArrowUp':
e.preventDefault();
filterMentionSelectedIndex = Math.max(filterMentionSelectedIndex - 1, 0);
updateFilterMentionHighlight(items);
return true;
case 'Enter':
case 'Tab':
if (items.length > 0 && filterMentionSelectedIndex < items.length) {
e.preventDefault();
const selected = items[filterMentionSelectedIndex];
if (selected && selected.dataset.contact) {
selectFilterMentionContact(selected.dataset.contact);
}
return true;
}
break;
}
return false;
}
/**
* Show filter mentions popup with filtered contacts
*/
function showFilterMentionsPopup(query) {
const popup = document.getElementById('filterMentionsPopup');
const list = document.getElementById('filterMentionsList');
// Ensure contacts are loaded
loadContactsForMentions();
const filtered = filterContacts(query);
if (filtered.length === 0) {
list.innerHTML = '<div class="mentions-empty">No contacts found</div>';
popup.classList.remove('hidden');
return;
}
if (filterMentionSelectedIndex >= filtered.length) {
filterMentionSelectedIndex = 0;
}
list.innerHTML = filtered.map((contact, index) => {
const highlighted = index === filterMentionSelectedIndex ? 'highlighted' : '';
const escapedName = escapeHtml(contact);
return `<div class="mention-item ${highlighted}" data-contact="${escapedName}" data-index="${index}">
<span class="mention-item-name">${escapedName}</span>
</div>`;
}).join('');
list.querySelectorAll('.mention-item').forEach(item => {
item.addEventListener('click', function() {
selectFilterMentionContact(this.dataset.contact);
});
});
popup.classList.remove('hidden');
}
/**
* Hide filter mentions popup
*/
function hideFilterMentionsPopup() {
const popup = document.getElementById('filterMentionsPopup');
if (popup) popup.classList.add('hidden');
filterMentionActive = false;
filterMentionStartPos = -1;
filterMentionSelectedIndex = 0;
}
/**
* Update highlight in filter mentions popup
*/
function updateFilterMentionHighlight(items) {
items.forEach((item, index) => {
if (index === filterMentionSelectedIndex) {
item.classList.add('highlighted');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('highlighted');
}
});
}
/**
* Select a contact from filter mentions and insert plain name
*/
function selectFilterMentionContact(contactName) {
const input = document.getElementById('filterInput');
const text = input.value;
// Replace from @ position to cursor with plain contact name
const beforeMention = text.substring(0, filterMentionStartPos);
const afterCursor = text.substring(input.selectionStart);
input.value = beforeMention + contactName + afterCursor;
// Set cursor position after the name
const newCursorPos = filterMentionStartPos + contactName.length;
input.setSelectionRange(newCursorPos, newCursorPos);
hideFilterMentionsPopup();
input.focus();
// Trigger filter with the new value
applyFilter(input.value);
}
/**
* Clear filter state when messages are reloaded
* Called from displayMessages()
+11 -2
View File
@@ -76,7 +76,13 @@
<!-- Filter bar overlay -->
<div id="filterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<div class="filter-input-wrapper">
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<!-- Filter mentions autocomplete popup -->
<div id="filterMentionsPopup" class="mentions-popup filter-mentions-popup hidden">
<div class="mentions-list" id="filterMentionsList"></div>
</div>
</div>
<span id="filterMatchCount" class="filter-match-count"></span>
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
@@ -153,7 +159,10 @@
</div>
<!-- Floating Action Buttons -->
<div class="fab-container">
<div class="fab-container" id="fabContainer">
<button class="fab fab-toggle" id="fabToggle" title="Hide buttons">
<i class="bi bi-chevron-right"></i>
</button>
<button class="fab fab-filter" id="filterFab" title="Filter Messages">
<i class="bi bi-funnel-fill"></i>
</button>