Complete 4.3.1: Notification System Redesign - Enhanced toast notification system with variants, queue management, and accessibility improvements

This commit is contained in:
VinnyNC 2025-09-29 23:24:56 -04:00
parent 54db215848
commit 60a5cbb576
4 changed files with 746 additions and 7 deletions

View file

@ -480,10 +480,19 @@ All utilities follow the established pattern of using CSS custom properties from
### Sub-task 4.3: Enhanced User Feedback Systems
#### 4.3.1: Notification Redesign
- [ ] Redesign toast notification system
- [ ] Implement notification variants
- [ ] Add notification queue management
#### 4.3.1: Notification Redesign - COMPLETED 9/29/2025
- [x] Redesign toast notification system
- [x] Implement notification variants
- [x] Add notification queue management
**Notes:** Comprehensive notification system redesign completed with:
- **Enhanced Notification System**: Replaced simple toast with advanced notification system supporting multiple variants (success, error, warning, info), custom actions, progress bars, and smooth animations
- **Queue Management**: Implemented intelligent queue system that handles up to 5 simultaneous notifications with automatic stacking and dismissal
- **Variants and Styling**: Four notification variants with distinct colors, icons, and animations (.notification--success, .notification--error, .notification--warning, .notification--info)
- **Advanced Features**: Hover-to-pause, keyboard navigation (Escape key), ARIA accessibility, progress indicators for auto-dismissing notifications, and smooth slide-in/out animations
- **Legacy Support**: Maintains backward compatibility with existing showToast() calls while redirecting to the new system
- **Mobile Responsive**: Full responsive design with mobile-specific positioning and touch-friendly interactions
- **Api Integration**: New Notifications API (show(), success(), error(), warning(), info()) with configuration options
#### 4.3.2: Loading States
- [ ] Implement loading indicators

420
assets/js/notifications.js Normal file
View file

@ -0,0 +1,420 @@
// Notification System Module
// Advanced notifications with variants, queue management, and accessibility
(function() {
'use strict';
AppModules.require('ui-controls');
AppModules.register('notifications');
// Notification configuration
const NotificationConfig = {
maxNotifications: 5, // Maximum notifications shown simultaneously
defaultDuration: 4000, // Default auto-dismiss duration (ms)
duration: {
success: 3000,
error: 7000,
warning: 5000,
info: 4000
},
position: 'top-right', // top-right, top-left, bottom-right, bottom-left, center
positionClasses: {
'top-right': '',
'top-left': 'notifications-container--left',
'bottom-right': 'notifications-container--bottom',
'bottom-left': 'notifications-container--bottom notifications-container--left',
'center': 'notifications-container--center'
}
};
// Notification queue and state
let notificationQueue = [];
let activeNotifications = [];
let notificationCounter = 0;
// DOM elements
let notificationsContainer = null;
// Notification class
class Notification {
constructor(options = {}) {
this.id = `notification-${++notificationCounter}`;
this.title = options.title || '';
this.message = options.message || '';
this.type = options.type || 'info'; // success, error, warning, info, default
this.duration = options.duration === 0 ? 0 : (options.duration || NotificationConfig.duration[this.type] || NotificationConfig.defaultDuration);
this.actions = options.actions || [];
this.position = options.position || NotificationConfig.position;
this.persistent = options.persistent || false;
this.icon = options.icon !== undefined ? options.icon : true;
this.timestamp = Date.now();
this.element = null;
}
createElement() {
const notification = document.createElement('div');
notification.id = this.id;
notification.className = `notification notification--${this.type}`;
notification.setAttribute('role', 'alert');
notification.setAttribute('aria-live', 'assertive');
notification.setAttribute('aria-atomic', 'true');
let html = '';
// Icon
if (this.icon) {
html += '<div class="notification__icon" aria-hidden="true"></div>';
}
// Content
html += '<div class="notification__content">';
if (this.title) {
html += `<div class="notification__title">${escapeHtml(this.title)}</div>`;
}
html += `<div class="notification__message">${escapeHtml(this.message)}</div>`;
html += '</div>';
// Actions
if (this.actions.length > 0) {
html += '<div class="notification__actions">';
this.actions.forEach(action => {
const actionId = `action-${this.id}-${action.id || action.label.toLowerCase().replace(/\s+/g, '-')}`;
html += `<button class="notification__action" data-action-id="${actionId}" aria-label="${escapeHtml(action.label)}">${escapeHtml(action.label)}</button>`;
});
html += '</div>';
}
// Progress bar for auto-dismissing notifications
if (this.duration > 0) {
html += '<div class="notification__progress"><div class="notification__progress-bar"></div></div>';
}
notification.innerHTML = html;
// Add close button if no custom actions
if (this.actions.length === 0) {
const closeBtn = document.createElement('button');
closeBtn.className = 'notification__action';
closeBtn.setAttribute('aria-label', 'Close notification');
closeBtn.innerHTML = '×';
closeBtn.addEventListener('click', () => this.dismiss());
notification.appendChild(closeBtn);
}
// Set progress bar animation duration
if (this.duration > 0) {
const progressBar = notification.querySelector('.notification__progress-bar');
if (progressBar) {
progressBar.style.animationDuration = `${this.duration}ms`;
}
}
// Bind action handlers
this.actions.forEach((action, index) => {
const actionBtn = notification.querySelector(`[data-action-id="action-${this.id}-${action.id || action.label.toLowerCase().replace(/\s+/g, '-')}"]`);
if (actionBtn && action.handler) {
actionBtn.addEventListener('click', () => {
action.handler(this);
});
}
});
this.element = notification;
return notification;
}
show() {
if (!notificationsContainer) return;
// Create element if not exists
if (!this.element) {
this.createElement();
}
// Add position class
const positionClass = NotificationConfig.positionClasses[this.position] || '';
notificationsContainer.className = `notifications-container ${positionClass}`;
// Add to container
notificationsContainer.appendChild(this.element);
// Set up auto-dismiss if duration is set
if (this.duration > 0) {
this.timeoutId = setTimeout(() => {
this.dismiss();
}, this.duration);
}
// Pause timeout on hover
this.element.addEventListener('mouseenter', () => {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
});
this.element.addEventListener('mouseleave', () => {
if (this.duration > 0 && !this.dismissed) {
this.timeoutId = setTimeout(() => {
this.dismiss();
}, this.duration);
}
});
return this;
}
dismiss() {
if (this.dismissed) return;
this.dismissed = true;
// Clear timeout
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
// Add exit animation
if (this.element) {
this.element.classList.add('exiting');
// Remove after animation
setTimeout(() => {
if (this.element && this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
removeFromActive(this);
processQueue();
}, 300);
}
}
update(options = {}) {
if (options.message !== undefined) {
this.message = options.message;
const messageEl = this.element.querySelector('.notification__message');
if (messageEl) {
messageEl.textContent = this.message;
}
}
if (options.title !== undefined) {
this.title = options.title;
const titleEl = this.element.querySelector('.notification__title');
if (titleEl) {
titleEl.textContent = this.title;
}
}
}
}
// Queue management functions
function addToQueue(notification) {
notificationQueue.push(notification);
// Process queue if not at max capacity
if (activeNotifications.length < NotificationConfig.maxNotifications) {
processQueue();
}
}
function removeFromActive(notification) {
const index = activeNotifications.indexOf(notification);
if (index > -1) {
activeNotifications.splice(index, 1);
}
}
function processQueue() {
// Remove any notifications that have been dismissed
activeNotifications = activeNotifications.filter(n => !n.dismissed);
// Show queued notifications if space available
while (activeNotifications.length < NotificationConfig.maxNotifications && notificationQueue.length > 0) {
const notification = notificationQueue.shift();
activeNotifications.push(notification);
notification.show();
}
}
// Public API functions
function show(options = {}) {
if (typeof options === 'string') {
options = { message: options };
}
const notification = new Notification(options);
addToQueue(notification);
return notification;
}
function success(message, options = {}) {
return show(Object.assign({
message,
type: 'success'
}, options));
}
function error(message, options = {}) {
return show(Object.assign({
message,
type: 'error'
}, options));
}
function warning(message, options = {}) {
return show(Object.assign({
message,
type: 'warning'
}, options));
}
function info(message, options = {}) {
return show(Object.assign({
message,
type: 'info'
}, options));
}
function dismissAll() {
// Clear queue
notificationQueue = [];
// Dismiss all active notifications
activeNotifications.forEach(notification => {
notification.dismiss();
});
activeNotifications = [];
}
function clearQueue() {
notificationQueue = [];
}
function setConfig(newConfig = {}) {
Object.assign(NotificationConfig, newConfig);
// Update container class if position changed
if (notificationsContainer && newConfig.position) {
const positionClass = NotificationConfig.positionClasses[newConfig.position] || '';
notificationsContainer.className = `notifications-container ${positionClass}`;
}
}
// Utility functions
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '&#039;'
};
return (text || '').replace(/[&<>"']/g, m => map[m]);
}
// Initialize the notification system
function initialize() {
// Get container element
notificationsContainer = document.getElementById('notificationsContainer');
if (!notificationsContainer) {
console.warn('Notification container element not found. Notifications will not be displayed.');
return;
}
// Set initial position class
const positionClass = NotificationConfig.positionClasses[NotificationConfig.position] || '';
notificationsContainer.className = `notifications-container ${positionClass}`;
// Add keyboard navigation support
document.addEventListener('keydown', handleKeyboardNavigation);
// Add container click delegation
notificationsContainer.addEventListener('click', handleContainerClick);
AppLogger.log('Notification system initialized');
}
// Keyboard navigation handler
function handleKeyboardNavigation(event) {
// Close notification on Escape
if (event.key === 'Escape') {
const activeNotification = activeNotifications[activeNotifications.length - 1];
if (activeNotification) {
activeNotification.dismiss();
event.preventDefault();
}
}
}
// Container click handler for delegation
function handleContainerClick(event) {
// Handle close button clicks
if (event.target.classList.contains('notification__action') &&
event.target.textContent === '×') {
const notificationEl = event.target.closest('.notification');
if (notificationEl) {
const notificationId = notificationEl.id;
const notification = activeNotifications.find(n => n.id === notificationId);
if (notification) {
notification.dismiss();
}
}
}
}
// Legacy toast support - redirect to new system
function legacyShowToast(message, duration) {
const toastElement = document.getElementById('toast');
if (toastElement) {
toastElement.style.display = 'none';
}
// Show using new system instead
show({
message,
duration: duration === 0 ? 0 : duration,
type: 'info',
position: 'bottom'
});
}
function legacyHideToast() {
// Legacy function - kept for compatibility, but new system is auto-managing
AppLogger.warn('hideToast() called - this is deprecated. Notifications auto-manage themselves now.');
}
// Update ui-controls module to use new notification system
function updateUIControls() {
if (window.UIControls && window.UIControls.showToast) {
window.UIControls.showToast = legacyShowToast;
window.UIControls.hideToast = legacyHideToast;
}
}
// Public API
window.Notifications = {
show: show,
success: success,
error: error,
warning: warning,
info: info,
dismissAll: dismissAll,
clearQueue: clearQueue,
setConfig: setConfig,
config: NotificationConfig
};
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
// Update UI controls after a short delay to ensure they load
setTimeout(updateUIControls, 100);
AppLogger.log('Notification system module loaded');
})();

View file

@ -495,8 +495,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
</aside>
</main>
<!-- Toast Notification -->
<div class="toast" id="toast"></div>
<!-- Enhanced Notification System -->
<div class="notifications-container" role="region" aria-live="assertive" aria-label="Notifications" id="notificationsContainer"></div>
<!-- Legacy Toast Notification (deprecated) -->
<div class="toast" id="toast" style="display: none;"></div>
<!-- Mobile Bottom Navigation -->
<nav class="mobile-nav" id="mobileNav" role="navigation" aria-label="Mobile navigation">
@ -533,5 +536,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
<script defer src="assets/js/ui-controls.js?v=1.4.4"></script>
<script defer src="assets/js/chat.js?v=1.4.4"></script>
<script defer src="assets/js/video-player.js?v=1.4.4"></script>
<script defer src="assets/js/notifications.js?v=1.4.4"></script>
</body>
</html>

View file

@ -1927,9 +1927,310 @@ a:active {
}
/* =================================================================
NOTIFICATIONS & TOASTS
NOTIFICATION SYSTEM - Enhanced with Variants & Queue
================================================================= */
/* Notification container for stacking multiple notifications */
.notifications-container {
position: fixed;
top: var(--spacing-5);
right: var(--spacing-5);
z-index: var(--z-toast);
pointer-events: none;
display: flex;
flex-direction: column;
gap: var(--spacing-3);
align-items: flex-end;
max-width: min(400px, calc(100vw - var(--spacing-5) * 2));
}
.notifications-container--bottom {
top: auto;
bottom: var(--spacing-5);
}
.notifications-container--center {
right: auto;
left: 50%;
transform: translateX(-50%);
align-items: center;
}
/* Base notification styles */
.notification {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-4);
border-radius: var(--border-radius-lg);
font-family: var(--font-family-base);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
box-shadow: var(--elevation-4);
border: 1px solid transparent;
min-width: 300px;
max-width: 100%;
pointer-events: auto;
transition: all var(--transition-fast);
animation: notification-slide-in var(--transition-normal) ease-out;
word-wrap: break-word;
overflow-wrap: break-word;
}
.notification.exiting {
animation: notification-slide-out 0.3s ease-in;
}
/* Notification variants */
.notification--success {
background: var(--color-success-container);
color: var(--color-on-success-container);
border-color: var(--color-success-outline);
}
.notification--success::before {
content: '✓';
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--color-success);
color: var(--color-on-success);
border-radius: 50%;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.notification--error {
background: var(--color-error-container);
color: var(--color-on-error-container);
border-color: var(--color-error-outline);
}
.notification--error::before {
content: '✕';
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--color-error);
color: var(--color-on-error);
border-radius: 50%;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.notification--warning {
background: var(--color-warning-container);
color: var(--color-on-warning-container);
border-color: var(--color-warning-outline);
}
.notification--warning::before {
content: '⚠';
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--color-warning);
color: var(--color-on-warning);
border-radius: 50%;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.notification--info {
background: var(--color-primary-container);
color: var(--color-on-primary-container);
border-color: var(--color-primary-outline);
}
.notification--info::before {
content: '';
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--color-primary);
color: var(--color-on-primary);
border-radius: 50%;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.notification--default {
background: var(--color-surface);
color: var(--color-on-surface);
border-color: var(--color-outline-variant);
}
/* Notification content area */
.notification__content {
flex: 1;
min-width: 0;
}
.notification__title {
font-weight: var(--font-weight-semibold);
margin-bottom: var(--spacing-1);
font-size: var(--font-size-sm);
}
.notification__message {
margin: 0;
font-size: var(--font-size-sm);
line-height: var(--line-height-normal);
}
/* Notification actions */
.notification__actions {
display: flex;
gap: var(--spacing-2);
margin-left: auto;
}
.notification__action {
background: transparent;
border: none;
color: inherit;
cursor: pointer;
padding: var(--spacing-1);
border-radius: var(--border-radius-sm);
font-size: 16px;
line-height: 1;
transition: all var(--transition-fast);
opacity: 0.7;
flex-shrink: 0;
}
.notification__action:hover {
opacity: 1;
background: rgba(var(--color-on-surface-rgb), 0.1);
}
.notification__action:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
opacity: 1;
}
/* Progress indicator for auto-dismissing notifications */
.notification__progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(var(--color-on-surface-rgb), 0.2);
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
overflow: hidden;
}
.notification__progress-bar {
height: 100%;
background: currentColor;
animation: notification-progress linear forwards;
}
/* Notification animations */
@keyframes notification-slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes notification-slide-out {
from {
opacity: 1;
transform: translateX(0) scale(1);
max-height: 200px;
margin-bottom: var(--spacing-3);
}
to {
opacity: 0;
transform: translateX(100%) scale(0.9);
max-height: 0;
margin-bottom: 0;
}
}
@keyframes notification-progress {
from { width: 100%; }
to { width: 0%; }
}
/* Notification enter animations for different positions */
.notifications-container--bottom .notification {
animation: notification-slide-up var(--transition-normal) ease-out;
}
.notifications-container--center .notification {
animation: notification-fade-in var(--transition-normal) ease-out;
}
@keyframes notification-slide-up {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes notification-fade-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Mobile responsive adjustments */
@media (max-width: calc(var(--breakpoint-sm) - 1px)) {
.notifications-container {
top: var(--spacing-4);
right: var(--spacing-4);
left: var(--spacing-4);
align-items: stretch;
}
.notifications-container--center {
right: var(--spacing-4);
left: var(--spacing-4);
transform: none;
}
.notification {
min-width: auto;
max-width: none;
}
.notification__content {
flex: 1;
}
}
/* Legacy toast support - deprecated, use notifications instead */
.toast {
position: fixed;
bottom: var(--spacing-5);
@ -1949,6 +2250,11 @@ a:active {
opacity: 1;
}
.toast:active {
transform: translateX(-50%) scale(0.95);
}
/* Video player notification badge - unchanged */
.video-player__notification-badge {
position: absolute;
top: calc(var(--spacing-2) * -1);