In large-scale digital products, even seemingly straightforward components like dropdown menus can evolve into challenges. They become brittle with patchwork adjustments, inconsistent behaviors, and an accumulation of redundant code across teams. This article explores the thoughtful construction of a reusable, accessible action menu feature for share buttons, highlighting the importance of usability, scalability, and maintainability in modern web applications.
Tackling common challenges in scalable UI
Implementing dropdown menus in an ad hoc manner often leads to:
- Code duplication: Multiple teams might create their own versions, leading to redundant code and increased maintenance effort.
- Usability pitfalls: Inconsistent interactions, such as varying hover and click behaviors, can confuse users.
- Accessibility oversights: Lack of keyboard navigation support and improper ARIA roles can exclude users who rely on assistive technologies.
- Performance concerns: Inefficient code and unnecessary dependencies can degrade application performance.
A common pitfall: complexity disguised as progress
For example, one team might implement a dropdown using a specific animation library, while another uses a different approach. This leads to inconsistencies and makes debugging difficult, as developers need to understand multiple implementations. Additionally, without standardization, accessibility features might be overlooked, leading to a non-inclusive product.
Reflecting on these challenges through my experience in designing and engineering for large-scale applications, I prioritized crafting a dropdown menu that is scalable, accessible, and built to integrate seamlessly into existing infrastructures.
Building the reusable action menu
To address these challenges, I developed a modular action menu component using modern JavaScript (ES6), ensuring it's lightweight and framework-agnostic. The component can be integrated into projects regardless of whether they use frameworks like React, Angular, or Vue.js.
Key principles driving the solution
- Usability-first design: Intuitive interactions that work seamlessly with both mouse and keyboard.
- Scalable architecture: Configuration via HTML attributes, eliminating the need for redundant JavaScript.
- Accessibility compliance: Full support for ARIA roles, keyboard navigation, and focus management.
- Viewport intelligence: Dynamic positioning to ensure menus stay within the viewport.
Technical implementation details
Clean markup-driven controls
The component is configured using HTML data attributes, allowing for easy customization without modifying the JavaScript code.
<div class="action-container">
<button
class="action-button"
data-trigger="click"
data-type="share"
aria-expanded="false"
>
Share
</button>
<div class="action-menu" role="menu">
<a href="#" class="action-item" role="menuitem">Copy Link</a>
<a href="#" class="action-item" role="menuitem">Share on Twitter</a>
<!-- More items -->
</div>
</div>
Why this approach?
- Separation of concerns: Keeps behavior and structure separate, enhancing maintainability.
- Ease of use: Developers can add or modify menus by updating the HTML markup alone.
The JavaScript module
The core functionality is encapsulated in a JavaScript module that handles event binding, menu toggling, and positioning.
// ActionButton.js
export function initActionButton({
selector = '.action-button',
containerSelector = '.action-container',
} = {}) {
document.querySelectorAll(containerSelector).forEach((container) => {
const button = container.querySelector(selector);
const menu = container.querySelector('.action-menu');
const triggerType = button.dataset.trigger || 'hover';
if (triggerType === 'hover') {
container.addEventListener('mouseenter', () => openActionMenu(button));
container.addEventListener('mouseleave', () => closeActionMenu(button));
} else {
button.addEventListener('click', () => toggleActionMenu(button));
}
button.addEventListener('keydown', (event) => handleKeyDown(event, button));
});
// Core functions: openActionMenu, closeActionMenu, toggleActionMenu
// Additional functions: adjustMenuPosition, handleKeyDown, closeAllMenus
function openActionMenu(button) {
const menu = button.nextElementSibling;
closeAllMenus();
menu.classList.add('open');
button.classList.add('active');
button.setAttribute('aria-expanded', 'true');
adjustMenuPosition(button, menu);
}
function closeActionMenu(button) {
const menu = button.nextElementSibling;
menu.classList.remove('open');
button.classList.remove('active');
button.setAttribute('aria-expanded', 'false');
}
function toggleActionMenu(button) {
const menu = button.nextElementSibling;
const isOpen = menu.classList.contains('open');
if (isOpen) {
closeActionMenu(button);
} else {
openActionMenu(button);
}
}
function closeAllMenus() {
document.querySelectorAll('.action-menu.open').forEach((menu) => {
menu.classList.remove('open');
const button = menu.previousElementSibling;
button.classList.remove('active');
button.setAttribute('aria-expanded', 'false');
});
}
}
Architectural decisions:
- Event delegation: Attaching events to containers reduces the number of event listeners and improves performance.
- State management via classes: Using CSS classes like
.open
and.active
simplifies state tracking and visual updates.
Dynamic positioning for edge cases
To ensure the menu stays within the viewport, especially when the trigger button is near the edges, the adjustMenuPosition
function calculates available space and adjusts accordingly.
function adjustMenuPosition(button, menu) {
// Reset position classes
menu.classList.remove('align-left', 'align-right');
const buttonRect = button.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (buttonRect.left + menuRect.width > viewportWidth) {
// Align to the right
menu.classList.add('align-right');
} else if (buttonRect.left < menuRect.width / 2) {
// Align to the left
menu.classList.add('align-left');
} else {
// Center align
menu.style.left = '50%';
menu.style.transform = 'translateX(-50%)';
}
}
Performance considerations:
- Efficient calculations: Using
getBoundingClientRect()
sparingly to minimize reflows. - CSS transforms: Leveraging hardware acceleration for smoother animations.
Accessibility and usability at the core
The component is designed to be fully accessible:
- ARIA roles and attributes: Properly set roles like
role="menu"
and managearia-expanded
. - Keyboard navigation: Supports navigation using Tab, Enter, Space, and arrow keys.
function handleKeyDown(event, button) {
const menu = button.nextElementSibling;
if (event.key =<mark> 'Enter' || event.key </mark>= ' ') {
event.preventDefault();
toggleActionMenu(button);
menu.querySelector('.action-item').focus();
}
}
menu.addEventListener('keydown', (event) => {
const items = Array.from(menu.querySelectorAll('.action-item'));
const currentIndex = items.indexOf(document.activeElement);
if (event.key === 'ArrowDown') {
event.preventDefault();
const nextIndex = (currentIndex + 1) % items.length;
items[nextIndex].focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
const prevIndex = (currentIndex - 1 + items.length) % items.length;
items[prevIndex].focus();
} else if (event.key === 'Escape') {
closeActionMenu(button);
button.focus();
}
});
Focus management:
- Trap focus within menu: Ensures that focus cycles through menu items.
- Return focus to trigger: When the menu is closed, focus returns to the trigger button.
Styling with SCSS
The SCSS styles handle the visual presentation and positioning of the menu.
.action-container {
position: relative;
}
.action-menu {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
// Hidden by default
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease;
&.open {
opacity: 1;
visibility: visible;
}
&.align-left {
left: 0;
transform: translateX(0);
}
&.align-right {
right: 0;
left: auto;
transform: translateX(0);
}
}
Why use CSS classes over inline styles?
Maintainability: Easier to update styles without changing JavaScript code.
Performance: Reduces layout thrashing by avoiding inline style changes.
Adapting for frameworks
While the component is built with vanilla JavaScript, it can be easily integrated into frameworks.
import { useEffect } from 'react';
import { initActionButton } from './ActionButton';
function ShareButton() {
useEffect(() => {
initActionButton();
}, []);
return (
<div className="action-container">
<button
className="action-button"
data-trigger="click"
data-type="share"
aria-expanded="false"
>
Share
</button>
<div className="action-menu" role="menu">
<a href="#" className="action-item" role="menuitem">
Copy Link
</a>
<a href="#" className="action-item" role="menuitem">
Share on Twitter
</a>
{/* More items */}
</div>
</div>
);
}
Considerations
Lifecycle : Use useEffect to initialize the component after the DOM is rendered.
Avoid direct DOM manipulation: Ensure that any DOM manipulations do not conflict with React’s virtual DOM.
Common customization scenarios
Changing the trigger type: Use data-trigger="hover" or data-trigger="click" to modify how the menu is activated.
Adding menu items: Simply add more <a>
elements within the .action-menu div.
Styling adjustments: Customize the SCSS variables or override styles as needed.
Potential pitfalls and solutions
Menu closes unexpectedly: Ensure that the container includes any gaps caused by design elements like arrow indicators.
Focus lost on close: Confirm that focus management returns the focus to the trigger button after closing.
Inviting exploration
I invite you to try the link – a live implementation of the action menu component discussed here. If you found it useful, consider sharing it with others.