diff --git a/.gitignore b/.gitignore index 250a7c02..83c9fad4 100755 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ report/* .idea # auto-gen PHP files/logs -php_errors.log +php_errors.log \ No newline at end of file diff --git a/content/body/toast.php b/content/body/toast.php new file mode 100644 index 00000000..0896e987 --- /dev/null +++ b/content/body/toast.php @@ -0,0 +1,166 @@ +

+ Toast notifications are non-blocking alerts that provide feedback or information to users. They are hidden by default and become visible when triggered by a user action or system event. Toast notifications can be styled, positioned, and customized to suit various needs. This demo showcases an accessible implementation of toast notifications. +

+ +

Shopping Site Toast Notifications Example

+ +
+
+
+

Products:

+ + + +
+
+ + +
+
+ + +
+
+
+ + + + + +

Accessible Toast Notifications Example

+ + true, + "comment" => "Recommended for new and existing work.", +]); ?> + true]); ?> + +

+ In order to make toast notifications accessible, there are a few considerations: +

+
    +
  1. Toasts should be announced by screen readers when they appear.
  2. +
  3. Keyboard users should be able to navigate to and dismiss toasts.
  4. +
  5. Toasts should support different levels of severity (normal, error, warning, success), each with a unique color for visual distinction.
  6. +
+

+ Our implementation ensures that toasts are fully accessible and follow best practices for ARIA and keyboard interaction. + When a toast appears, it is announced to screen readers. Toasts will remain visible until manually dismissed by the user. + Additionally, all toasts are stored in a toast rack for future reference. +

+ +
+
+
+ + +
+
+ +
+ + + + + + +
+
+
+ +
+ + + + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+ + + + + diff --git a/content/bottom/toast.php b/content/bottom/toast.php new file mode 100644 index 00000000..66b6cebb --- /dev/null +++ b/content/bottom/toast.php @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/content/head/toast.php b/content/head/toast.php new file mode 100644 index 00000000..0dfde4c8 --- /dev/null +++ b/content/head/toast.php @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/css/app.css b/css/app.css new file mode 100644 index 00000000..135c728e --- /dev/null +++ b/css/app.css @@ -0,0 +1,59 @@ +body { + font-family: Arial, sans-serif; + display: flex; + flex-direction: column; + align-items: center; + margin: 20px; +} + +.controls { + margin-bottom: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.control-group { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 10px; +} + +.control-group label { + font-weight: normal; +} + +.control-group div { + display: flex; + gap: 10px; +} + +.button-group { + display: flex; + gap: 10px; +} + +.toast-rack { + position: fixed; + top: 20px; + right: 20px; + background: rgba(241, 241, 241, 0.9); + border: 1px solid #ddd; + padding: 10px; + width: 300px; + max-height: 400px; + overflow-y: auto; + display: none; + z-index: 9998; +} + +.toast-rack button { + display: block; + margin-bottom: 10px; +} + +.status { + margin-top: 20px; + font-size: 16px; +} diff --git a/css/toast-demo.css b/css/toast-demo.css new file mode 100644 index 00000000..e0a0c11c --- /dev/null +++ b/css/toast-demo.css @@ -0,0 +1,18 @@ +.enable-toast__controls { + margin-bottom: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.625rem; +} +.enable-toast__controls__control-group { + display: flex; + flex-direction: column; + margin-bottom: 0.625rem; +} +.enable-toast__controls__label { + font-weight: normal; +} +.enable-toast__controls__button-group { + display: flex; + gap: 0.625rem; +} diff --git a/css/toast.css b/css/toast.css new file mode 100644 index 00000000..57ca2d95 --- /dev/null +++ b/css/toast.css @@ -0,0 +1,76 @@ +.enable-toast__container { + position: fixed; + z-index: 9999; + display: flex; + transition: all 0.5s ease-in-out; +} +.enable-toast__container--bottom-right, +.enable-toast__container--bottom-left, +.enable-toast__container--bottom-center { + bottom: 20px; +} +.enable-toast__container--top-right, +.enable-toast__container--top-left, +.enable-toast__container--top-center { + top: 20px; +} +.enable-toast__container--bottom-right, +.enable-toast__container--top-right { + right: 20px; +} +.enable-toast__container--bottom-left, +.enable-toast__container--top-left { + left: 20px; +} +.enable-toast__container--top-center, +.enable-toast__container--bottom-center, +.enable-toast__container--middle-center { + left: 50%; + transform: translateX(-50%); +} +.enable-toast__container--middle-center { + top: 50%; + transform: translate(-50%, -50%); +} +.enable-toast__toast { + display: flex; + align-items: center; + justify-content: space-between; + margin: 10px 0; + background: #333; + color: #fff; + padding: 10px; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + opacity: 0; + transition: opacity 0.5s ease-in-out; +} +.enable-toast__toast--visible { + opacity: 1; +} +.enable-toast__toast--exit { + opacity: 0; +} +.enable-toast__close-button { + background: none; + border: none; + color: #fff; + font-size: 16px; + cursor: pointer; +} +.enable-toast__close-button:hover { + color: #ff0000; +} +.enable-toast__rack { + position: fixed; + top: 20px; + right: 20px; + background: rgba(241, 241, 241, 0.9); + border: 1px solid #ddd; + padding: 10px; + width: 300px; + max-height: 400px; + overflow-y: auto; + display: none; + z-index: 9998; +} diff --git a/html_example/toast_example.html b/html_example/toast_example.html new file mode 100644 index 00000000..2b0fecf5 --- /dev/null +++ b/html_example/toast_example.html @@ -0,0 +1,210 @@ + + + + + + Accessible Toast Notifications + + + + + +

+ Toast notifications are non-blocking alerts that provide + feedback or information to users. They are hidden by default and + become visible when triggered by a user action or system + event. + Toast notifications can be styled, positioned, and customized to + suit various needs. This demo showcases an accessible implementation + of toast notifications. +

+ +

Accessible Toast Notifications Example

+ +

+ In order to make toast notifications accessible, there are a few + considerations: +

+
    +
  1. + Toasts should be announced by screen readers when they appear. +
  2. +
  3. + Keyboard users should be able to navigate to and dismiss toasts. +
  4. +
  5. + Toasts should support different levels of severity (normal, + error, warning, success), each with a unique color for visual + distinction. +
  6. +
+

+ Our implementation ensures that toasts are fully accessible and + follow best practices for ARIA and keyboard interaction. When a + toast appears, it is announced to screen readers. Toasts can be + configured to stay visible for a set amount of time or until + manually dismissed by the user. Additionally, all toasts are stored + in a toast rack for future reference. +

+ +
+
+
+ + +
+
+ +
+ + + + + + +
+
+
+ +
+ + + + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+ + + + diff --git a/js/demos/toast.js b/js/demos/toast.js new file mode 100644 index 00000000..b984269c --- /dev/null +++ b/js/demos/toast.js @@ -0,0 +1,101 @@ +'use strict'; + +import toastModule from '../modules/toast.js'; + +const app = new (function () { + console.log('toast demo!!!'); + let toastIndex = 0; + + this.init = function () { + this.toast = new toastModule.Toast({ + position: 'bottom-right', + style: 'padding: 10px; border-radius: 5px;', + alertArea: true, + maxVisible: 2, + levels: { + normal: { color: '#007bff' }, + error: { color: '#dc3545' }, + warning: { color: '#ffc107' }, + success: { color: '#28a745' }, + }, + ariaLive: 'polite', // Default aria-live value + }); + + this.attachEventListeners(); + }; + + this.attachEventListeners = function () { + document + .getElementById('showToastButton') + .addEventListener('click', this.showToastButtonClickEvent); + document + .getElementById('clearAllButton') + .addEventListener('click', this.clearAllButtonClickEvent); + document + .getElementById('toggleRackButton') + .addEventListener('click', this.toggleRackButtonClickEvent); + + document.querySelectorAll('input[name="position"]').forEach((radio) => { + radio.addEventListener('change', this.positionChangeEvent); + }); + }; + + this.showToastButtonClickEvent = (e) => { + const message = document.getElementById('messageInput').value; + const level = document.querySelector( + 'input[name="level"]:checked', + ).value; + const ariaLive = document.querySelector( + 'input[name="ariaLive"]:checked', + ).value; + this.toast.ariaLive = ariaLive; // Set aria-live value + toastIndex++; + const fullMessage = `Toast ${toastIndex}: ${message}`; + try { + this.toast.showToast(fullMessage, level); + } catch (error) { + console.error('Error showing toast:', error); + } + this.updateStatus(); + }; + + this.clearAllButtonClickEvent = (e) => { + try { + this.toast.clearAllToasts(); + } catch (error) { + console.error('Error clearing toasts:', error); + } + this.updateStatus(); + }; + + this.toggleRackButtonClickEvent = (e) => { + const rack = document.getElementById('toastRack'); + if (rack.style.display === 'block') { + rack.style.display = 'none'; + } else { + rack.style.display = 'block'; + } + console.log( + `Toast rack ${rack.style.display === 'block' ? 'shown' : 'hidden'}`, + ); + }; + + this.positionChangeEvent = (e) => { + try { + this.toast.container.className = `enable-toast enable-toast--${e.target.value}`; + } catch (error) { + console.error('Error changing toast position:', error); + } + }; + + this.updateStatus = function () { + const totalVisible = this.toast.visibleQueue.length; + const totalNotifications = this.toast.toastQueue.length; + document.getElementById('status').textContent = + `Visible: ${totalVisible}, Total: ${totalNotifications}`; + document.getElementById('rackContentStatus').textContent = + `Toast rack contains ${totalNotifications} toasts.`; + }; +})(); + +export default app; diff --git a/js/modules/es4/toast.js b/js/modules/es4/toast.js new file mode 100644 index 00000000..1da066f7 --- /dev/null +++ b/js/modules/es4/toast.js @@ -0,0 +1,191 @@ +'use strict' + +/******************************************************************************* + * toast.js - UI for Accessible Toast Notifications + * + * Written by Otto Wachter + * Part of the Enable accessible component library. + * Version 1.x released [Release Date] + * + * More information about this script available at: + * https://www.useragentman.com/enable/toast.php + * + * Released under the MIT License. + ******************************************************************************/ + +const toastModule = new function() { + + this.init = function() { + this.rackContent = document.getElementById('rackContent'); + this.updateToggleRackButton(); + }; + + this.Toast = class { + constructor(options = {}) { + this.container = this.createContainer(options.position || 'bottom-right'); + this.maxVisible = options.maxVisible || 1; + this.toastQueue = []; + this.visibleQueue = []; + this.levels = options.levels || {}; + this.ariaLive = options.ariaLive || 'polite'; // Default to polite + document.body.appendChild(this.container); + + // Initialize the toast rack + this.rackContent = document.getElementById('rackContent'); + this.toggleRackButton = document.getElementById('toggleRackButton'); + } + + // Create the container for the toasts + createContainer(position) { + const container = document.createElement('div'); + container.className = `enable-toast__container enable-toast__container--${position}`; + return container; + } + + // Show a new toast notification + showToast(message, level = 'normal') { + const toastData = { + message, + level, + id: Date.now() // unique id for each toast + }; + this.toastQueue.push(toastData); + this.visibleQueue.push(toastData); + console.log("Toast added to queue and visibleQueue:", toastData); + const toastElement = this.createToastElement(toastData); + this.container.appendChild(toastElement); + toastElement.offsetHeight; // Force reflow to ensure the element is rendered before adding the visible class + + setTimeout(() => { + toastElement.classList.add('enable-toast__item--visible'); + }, 100); // Slight delay to ensure screen readers catch the change + + // Update the toast rack with the new toast + this.updateToastRack(); + this.updateVisibleToasts(); + this.updateToggleRackButton(); + } + + // Create the individual toast element (Using enable-toast__item) + createToastElement(toastData) { + const { message, level, id } = toastData; + const toast = document.createElement('div'); + toast.className = 'enable-toast__item'; + toast.style.backgroundColor = this.levels[level]?.color || '#333'; + toast.setAttribute('tabindex', '-1'); + toast.setAttribute('aria-live', this.ariaLive); + toast.setAttribute('role', 'alert'); + toast.setAttribute('data-id', id); + + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + toast.appendChild(messageSpan); + + const closeButton = document.createElement('button'); + closeButton.className = 'enable-toast__close'; + closeButton.setAttribute('aria-label', 'close alert'); + closeButton.textContent = '✖'; + closeButton.addEventListener('click', () => { + console.log("Close button clicked for toast:", message); + this.dismissToast(toastData); + }); + toast.appendChild(closeButton); + + return toast; + } + + // Dismiss a toast notification + dismissToast(toastData) { + const toastElement = this.container.querySelector(`[data-id="${toastData.id}"]`); + if (toastElement) { + toastElement.classList.add('enable-toast__item--exit'); + setTimeout(() => { + toastElement.remove(); + this.visibleQueue = this.visibleQueue.filter(t => t.id !== toastData.id); + this.updateVisibleToasts(); + this.updateToastRack(); + this.updateToggleRackButton(); + }, 500); // Match the CSS animation duration + } + } + + // Update the visible toasts according to the max visible limit + updateVisibleToasts() { + console.log("Updating visible toasts"); + + // Remove excess toasts from the visibleQueue + while (this.visibleQueue.length > this.maxVisible) { + const toastToRemove = this.visibleQueue.shift(); + this.dismissToast(toastToRemove); + } + + // Ensure only maxVisible toasts are displayed + this.visibleQueue.forEach((toast, index) => { + const toastElement = this.container.querySelector(`[data-id="${toast.id}"]`); + if (index < this.maxVisible) { + toastElement.classList.add('enable-toast__item--visible'); + } else { + toastElement.classList.remove('enable-toast__item--visible'); + } + }); + + this.updateStatus(); + } + + // Update the status of the toasts + updateStatus() { + const totalVisible = this.visibleQueue.length; + const totalNotifications = this.toastQueue.length; + document.getElementById('status').textContent = `Total Visible: ${totalVisible}, Total Notifications: ${totalNotifications}`; + } + + // Update the toast rack + updateToastRack() { + console.log("Updating toast rack"); + this.rackContent.innerHTML = ''; + this.toastQueue.forEach(toastData => { + const { message, level, id } = toastData; + const toastElement = document.createElement('div'); + toastElement.className = 'enable-toast__item enable-toast__item--visible'; + toastElement.style.backgroundColor = this.levels[level]?.color || '#333'; + toastElement.setAttribute('data-id', id); + + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + toastElement.appendChild(messageSpan); + + console.log(`Appending toast to rack: ${message}`); + this.rackContent.appendChild(toastElement); + }); + const totalNotifications = this.toastQueue.length; + console.log(`Toast rack updated with ${totalNotifications} toasts.`); + this.rackContent.setAttribute('aria-live', 'assertive'); + document.getElementById('rackContentStatus').textContent = `Toast rack contains ${totalNotifications} toasts.`; + } + + // Update the toggle rack button with the number of toasts + updateToggleRackButton() { + const totalNotifications = this.toastQueue.length; + if (this.toggleRackButton) { + this.toggleRackButton.textContent = `Toggle Toast Rack (${totalNotifications})`; + } + } + + // Clear all toasts + clearAllToasts() { + this.visibleQueue.forEach(toast => { + const toastElement = this.container.querySelector(`[data-id="${toast.id}"]`); + if (toastElement) { + toastElement.remove(); + } + }); + this.toastQueue = []; + this.visibleQueue = []; + console.log("All toasts cleared"); + this.updateStatus(); + this.updateToastRack(); + this.updateToggleRackButton(); + } + } +}; + diff --git a/js/modules/toast.js b/js/modules/toast.js new file mode 100644 index 00000000..fbf32cf8 --- /dev/null +++ b/js/modules/toast.js @@ -0,0 +1,192 @@ +'use strict' + +/******************************************************************************* + * toast.js - UI for Accessible Toast Notifications + * + * Written by Otto Wachter + * Part of the Enable accessible component library. + * Version 1.x released [Release Date] + * + * More information about this script available at: + * https://www.useragentman.com/enable/toast.php + * + * Released under the MIT License. + ******************************************************************************/ + +const toastModule = new function() { + + this.init = function() { + this.rackContent = document.getElementById('rackContent'); + this.updateToggleRackButton(); + }; + + this.Toast = class { + constructor(options = {}) { + this.container = this.createContainer(options.position || 'bottom-right'); + this.maxVisible = options.maxVisible || 1; + this.toastQueue = []; + this.visibleQueue = []; + this.levels = options.levels || {}; + this.ariaLive = options.ariaLive || 'polite'; // Default to polite + document.body.appendChild(this.container); + + // Initialize the toast rack + this.rackContent = document.getElementById('rackContent'); + this.toggleRackButton = document.getElementById('toggleRackButton'); + } + + // Create the container for the toasts + createContainer(position) { + const container = document.createElement('div'); + container.className = `enable-toast__container enable-toast__container--${position}`; + return container; + } + + // Show a new toast notification + showToast(message, level = 'normal') { + const toastData = { + message, + level, + id: Date.now() // unique id for each toast + }; + this.toastQueue.push(toastData); + this.visibleQueue.push(toastData); + console.log("Toast added to queue and visibleQueue:", toastData); + const toastElement = this.createToastElement(toastData); + this.container.appendChild(toastElement); + toastElement.offsetHeight; // Force reflow to ensure the element is rendered before adding the visible class + + setTimeout(() => { + toastElement.classList.add('enable-toast__item--visible'); + }, 100); // Slight delay to ensure screen readers catch the change + + // Update the toast rack with the new toast + this.updateToastRack(); + this.updateVisibleToasts(); + this.updateToggleRackButton(); + } + + // Create the individual toast element (Using enable-toast__item) + createToastElement(toastData) { + const { message, level, id } = toastData; + const toast = document.createElement('div'); + toast.className = 'enable-toast__item'; + toast.style.backgroundColor = this.levels[level]?.color || '#333'; + toast.setAttribute('tabindex', '-1'); + toast.setAttribute('aria-live', this.ariaLive); + toast.setAttribute('role', 'alert'); + toast.setAttribute('data-id', id); + + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + toast.appendChild(messageSpan); + + const closeButton = document.createElement('button'); + closeButton.className = 'enable-toast__close'; + closeButton.setAttribute('aria-label', 'close alert'); + closeButton.textContent = '✖'; + closeButton.addEventListener('click', () => { + console.log("Close button clicked for toast:", message); + this.dismissToast(toastData); + }); + toast.appendChild(closeButton); + + return toast; + } + + // Dismiss a toast notification + dismissToast(toastData) { + const toastElement = this.container.querySelector(`[data-id="${toastData.id}"]`); + if (toastElement) { + toastElement.classList.add('enable-toast__item--exit'); + setTimeout(() => { + toastElement.remove(); + this.visibleQueue = this.visibleQueue.filter(t => t.id !== toastData.id); + this.updateVisibleToasts(); + this.updateToastRack(); + this.updateToggleRackButton(); + }, 500); // Match the CSS animation duration + } + } + + // Update the visible toasts according to the max visible limit + updateVisibleToasts() { + console.log("Updating visible toasts"); + + // Remove excess toasts from the visibleQueue + while (this.visibleQueue.length > this.maxVisible) { + const toastToRemove = this.visibleQueue.shift(); + this.dismissToast(toastToRemove); + } + + // Ensure only maxVisible toasts are displayed + this.visibleQueue.forEach((toast, index) => { + const toastElement = this.container.querySelector(`[data-id="${toast.id}"]`); + if (index < this.maxVisible) { + toastElement.classList.add('enable-toast__item--visible'); + } else { + toastElement.classList.remove('enable-toast__item--visible'); + } + }); + + this.updateStatus(); + } + + // Update the status of the toasts + updateStatus() { + const totalVisible = this.visibleQueue.length; + const totalNotifications = this.toastQueue.length; + document.getElementById('status').textContent = `Total Visible: ${totalVisible}, Total Notifications: ${totalNotifications}`; + } + + // Update the toast rack + updateToastRack() { + console.log("Updating toast rack"); + this.rackContent.innerHTML = ''; + this.toastQueue.forEach(toastData => { + const { message, level, id } = toastData; + const toastElement = document.createElement('div'); + toastElement.className = 'enable-toast__item enable-toast__item--visible'; + toastElement.style.backgroundColor = this.levels[level]?.color || '#333'; + toastElement.setAttribute('data-id', id); + + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + toastElement.appendChild(messageSpan); + + console.log(`Appending toast to rack: ${message}`); + this.rackContent.appendChild(toastElement); + }); + const totalNotifications = this.toastQueue.length; + console.log(`Toast rack updated with ${totalNotifications} toasts.`); + this.rackContent.setAttribute('aria-live', 'assertive'); + document.getElementById('rackContentStatus').textContent = `Toast rack contains ${totalNotifications} toasts.`; + } + + // Update the toggle rack button with the number of toasts + updateToggleRackButton() { + const totalNotifications = this.toastQueue.length; + if (this.toggleRackButton) { + this.toggleRackButton.textContent = `Toggle Toast Rack (${totalNotifications})`; + } + } + + // Clear all toasts + clearAllToasts() { + this.visibleQueue.forEach(toast => { + const toastElement = this.container.querySelector(`[data-id="${toast.id}"]`); + if (toastElement) { + toastElement.remove(); + } + }); + this.toastQueue = []; + this.visibleQueue = []; + console.log("All toasts cleared"); + this.updateStatus(); + this.updateToastRack(); + this.updateToggleRackButton(); + } + } +}; + +export default toastModule; diff --git a/js/test/toast-1.js b/js/test/toast-1.js new file mode 100644 index 00000000..353dec88 --- /dev/null +++ b/js/test/toast-1.js @@ -0,0 +1,67 @@ +// __tests__/toast.test.js +import { Toast } from '../js/modules/toast'; + +describe('Toast', () => { + let toast; + let mockContainer; + + beforeEach(() => { + document.body.innerHTML = + '
'; + mockContainer = document.createElement('div'); + document.body.appendChild(mockContainer); + toast = new Toast({ position: 'bottom-right', maxVisible: 2 }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should create a toast container with the correct position', () => { + const container = toast.createContainer('top-left'); + expect(container.className).toBe( + 'enable-toast__container enable-toast__container--top-left', + ); + }); + + it('should show a toast with the correct message and level', () => { + toast.showToast('Test message', 'success'); + const toastElement = toast.container.querySelector( + '.enable-toast__toast', + ); + expect(toastElement).not.toBeNull(); + expect(toastElement.querySelector('span').textContent).toBe( + 'Test message', + ); + expect(toastElement.style.backgroundColor).toBe('#28a745'); // success level color + }); + + it('should dismiss a toast when the close button is clicked', () => { + toast.showToast('Test message', 'normal'); + const toastElement = toast.container.querySelector( + '.enable-toast__toast', + ); + toastElement.querySelector('.enable-toast__close-button').click(); + expect( + toastElement.classList.contains('enable-toast__toast--exit'), + ).toBe(true); + }); + + it('should update visible toasts correctly', () => { + toast.showToast('Message 1', 'normal'); + toast.showToast('Message 2', 'normal'); + toast.showToast('Message 3', 'normal'); + expect(toast.visibleQueue.length).toBe(2); // maxVisible is 2 + expect(toast.toastQueue.length).toBe(3); + }); + + it('should toggle toast rack visibility', () => { + const toggleRackButton = document.getElementById('toggleRackButton'); + const rack = document.getElementById('rackContent'); + rack.style.display = 'none'; + toggleRackButton.click(); + expect(rack.style.display).toBe('block'); + toggleRackButton.click(); + expect(rack.style.display).toBe('none'); + }); +}); diff --git a/js/test/toast.js b/js/test/toast.js new file mode 100644 index 00000000..1112e239 --- /dev/null +++ b/js/test/toast.js @@ -0,0 +1,131 @@ +// __tests__/toast.test.js +import { Toast } from '../js/modules/toast'; + +describe('Toast Module', () => { + let toast; + let container; + + beforeEach(() => { + document.body.innerHTML = + '
'; + container = document.createElement('div'); + container.id = 'toastContainer'; + document.body.appendChild(container); + + toast = new Toast({ + position: 'bottom-right', + maxVisible: 3, + levels: { + normal: { color: '#007bff' }, + error: { color: '#dc3545' }, + warning: { color: '#ffc107' }, + success: { color: '#28a745' }, + }, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should create a container with correct position', () => { + expect( + toast.container.classList.contains( + 'enable-toast__container--bottom-right', + ), + ).toBe(true); + }); + + it('should display a toast message with correct level', () => { + toast.showToast('This is a normal toast', 'normal'); + const toastElement = toast.container.querySelector( + '.enable-toast__toast', + ); + expect(toastElement).not.toBeNull(); + expect(toastElement.style.backgroundColor).toBe('#007bff'); + expect( + toastElement.textContent.includes('This is a normal toast'), + ).toBe(true); + }); + + it('should display multiple toasts up to maxVisible limit', () => { + toast.showToast('Toast 1', 'normal'); + toast.showToast('Toast 2', 'success'); + toast.showToast('Toast 3', 'warning'); + toast.showToast('Toast 4', 'error'); + + const visibleToasts = toast.container.querySelectorAll( + '.enable-toast__toast--visible', + ); + expect(visibleToasts.length).toBe(3); + expect(visibleToasts[0].textContent.includes('Toast 2')).toBe(true); + expect(visibleToasts[1].textContent.includes('Toast 3')).toBe(true); + expect(visibleToasts[2].textContent.includes('Toast 4')).toBe(true); + }); + + it('should remove toast from DOM when dismissed', () => { + toast.showToast('Dismiss me', 'normal'); + const toastElement = toast.container.querySelector( + '.enable-toast__toast', + ); + const closeButton = toastElement.querySelector( + '.enable-toast__close-button', + ); + + closeButton.click(); + + setTimeout(() => { + expect(toastElement.parentNode).toBeNull(); + }, 500); + }); + + it('should update aria-live attribute when specified', () => { + toast.ariaLive = 'assertive'; + toast.showToast('Assertive toast', 'normal'); + const toastElement = toast.container.querySelector( + '.enable-toast__toast', + ); + expect(toastElement.getAttribute('aria-live')).toBe('assertive'); + }); + + it('should add and remove toasts from rack correctly', () => { + toast.showToast('Toast in rack', 'normal'); + const rackContent = document.getElementById('rackContent'); + const rackToasts = rackContent.querySelectorAll('.enable-toast__toast'); + expect(rackToasts.length).toBe(1); + + toast.dismissToast(toast.toastQueue[0]); + setTimeout(() => { + expect( + rackContent.querySelectorAll('.enable-toast__toast').length, + ).toBe(0); + }, 500); + }); + + it('should toggle the toast rack visibility', () => { + const toggleRackButton = document.getElementById('toggleRackButton'); + const rack = document.getElementById('toastRack'); + + toggleRackButton.click(); + expect(rack.style.display).toBe('block'); + + toggleRackButton.click(); + expect(rack.style.display).toBe('none'); + }); + + it('should handle keyboard navigation for dismissing toasts', () => { + toast.showToast('Keyboard dismiss', 'normal'); + const toastElement = toast.container.querySelector( + '.enable-toast__toast', + ); + toastElement.focus(); + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + toastElement.dispatchEvent(event); + + setTimeout(() => { + expect( + toastElement.classList.contains('enable-toast__toast--exit'), + ).toBe(true); + }, 500); + }); +}); diff --git a/less/toast-demo.less b/less/toast-demo.less new file mode 100644 index 00000000..f10cf6fd --- /dev/null +++ b/less/toast-demo.less @@ -0,0 +1,27 @@ +// Variables +@margin: 1.25rem; // 20px converted to rem (assuming base font-size is 16px) +@gap: 0.625rem; // 10px converted to rem +@font-size: 1rem; // 16px converted to rem + +// Controls styles +.enable-toast__controls { + margin-bottom: @margin; + display: flex; + flex-direction: column; + gap: @gap; + + &__control-group { + display: flex; + flex-direction: column; + margin-bottom: @gap; + } + + &__label { + font-weight: normal; + } + + &__button-group { + display: flex; + gap: @gap; + } +} diff --git a/less/toast.less b/less/toast.less new file mode 100644 index 00000000..b2da24b7 --- /dev/null +++ b/less/toast.less @@ -0,0 +1,109 @@ +// Variables +@toast-background: #333; +@toast-color: #fff; +@toast-padding: 10px; +@toast-border-radius: 5px; +@toast-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +@toast-transition: all 0.5s ease-in-out; +@toast-close-color: @toast-color; +@toast-close-hover-color: #ff0000; +@rack-background: rgba(241, 241, 241, 0.9); +@rack-border: 1px solid #ddd; +@rack-padding: 10px; +@rack-width: 300px; +@rack-max-height: 400px; + +// Toast container positions +.enable-toast { + &__container { + position: fixed; + z-index: 9999; + display: flex; + flex-direction: column; + transition: @toast-transition; + + &--bottom-right, + &--bottom-left, + &--bottom-center { + bottom: 20px; + } + + &--top-right, + &--top-left, + &--top-center { + top: 20px; + } + + &--bottom-right, + &--top-right { + right: 20px; + } + + &--bottom-left, + &--top-left { + left: 20px; + } + + &--top-center, + &--bottom-center, + &--middle-center { + left: 50%; + transform: translateX(-50%); + } + + &--middle-center { + top: 50%; + transform: translate(-50%, -50%); + } + } + + // Toast item styles + &__item { + display: flex; + align-items: center; + justify-content: space-between; + margin: 10px 0; + background: @toast-background; + color: @toast-color; + padding: @toast-padding; + border-radius: @toast-border-radius; + box-shadow: @toast-box-shadow; + opacity: 0; + transition: opacity 0.5s ease-in-out; + + &--visible { + opacity: 1; + } + + &--exit { + opacity: 0; + } + } + + &__close { + background: none; + border: none; + color: @toast-close-color; + font-size: 16px; + cursor: pointer; + + &:hover { + color: @toast-close-hover-color; + } + } + + // Toast rack + &__rack { + position: fixed; + top: 20px; + right: 20px; + background: @rack-background; + border: @rack-border; + padding: @rack-padding; + width: @rack-width; + max-height: @rack-max-height; + overflow-y: auto; + display: none; + z-index: 9998; + } +}