mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-30 16:14:06 +00:00
* feat: use common dropdown component * fix: update example ui to match v3 * fix: test cases, bugs * fix: review comments * fix: review comments * fix: review * fix: file body/binary table within response examples * fix: file name, close btn not visible issue * fix: unnessary transition for three dots * fix: install missing deps in bruno-app * update example url when param is updated * empty commit * chore: update package-lock.json --------- Co-authored-by: Bijin A B <bijin@usebruno.com>
288 lines
9.2 KiB
JavaScript
288 lines
9.2 KiB
JavaScript
import React, { forwardRef, useImperativeHandle } from 'react';
|
|
import { useRef, useCallback, useState } from 'react';
|
|
import Dropdown from 'components/Dropdown';
|
|
|
|
// Constants
|
|
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'];
|
|
const ACTION_KEYS = ['Enter', ' '];
|
|
|
|
// Calculate next index for keyboard navigation
|
|
const getNextIndex = (currentIndex, total, key, noFocus) => {
|
|
if (key === 'Home') return 0;
|
|
if (key === 'End') return total - 1;
|
|
if (key === 'ArrowDown') return noFocus ? 0 : (currentIndex + 1) % total;
|
|
if (key === 'ArrowUp') return noFocus ? total - 1 : (currentIndex - 1 + total) % total;
|
|
return currentIndex;
|
|
};
|
|
|
|
/**
|
|
* MenuDropdown - A reusable dropdown menu component with keyboard navigation
|
|
*
|
|
* @param {Object} props
|
|
* @param {Array} props.items - Array of menu items with structure:
|
|
* - id: string (unique identifier)
|
|
* - type: 'item' | 'label' | 'divider' (default: 'item')
|
|
* - leftSection: React component or React element (rendered on the left side, for items only)
|
|
* - rightSection: React component or React element (rendered on the right side, for items only)
|
|
* - label: string (display text for items, or label text for labels; also used for aria-label and title if not provided)
|
|
* - ariaLabel: string (accessibility label, falls back to label or title if not provided)
|
|
* - onClick: function (handler when item is clicked, for items only)
|
|
* - title: string (tooltip text, falls back to label or ariaLabel if not provided)
|
|
* - testId: string (optional, for testing, for items only)
|
|
* - disabled: boolean (optional, for items only)
|
|
* - className: string (optional, additional CSS classes for the item)
|
|
* @param {ReactNode} props.children - The trigger element (button, etc.)
|
|
* @param {string} props.placement - Tippy placement (default: 'bottom-end')
|
|
* @param {string} props.className - Optional className for the dropdown
|
|
* @param {string} props.selectedItemId - Optional ID of the selected/active item to focus on open
|
|
* @param {Object} props.dropdownProps - Other props passed to underlying Dropdown component
|
|
* @param {React.Ref} ref - Optional ref to expose open/close methods
|
|
*/
|
|
const MenuDropdown = forwardRef(({
|
|
items = [],
|
|
children,
|
|
placement = 'bottom-end',
|
|
className,
|
|
selectedItemId,
|
|
'data-testid': testId = 'menu-dropdown',
|
|
...dropdownProps
|
|
}, ref) => {
|
|
const tippyRef = useRef();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
// Expose open/close methods via ref
|
|
useImperativeHandle(ref, () => ({
|
|
open: () => setIsOpen(true),
|
|
close: () => setIsOpen(false),
|
|
toggle: () => setIsOpen((prev) => !prev)
|
|
}), []);
|
|
|
|
// Get all focusable menu items from the menu dropdown
|
|
const getMenuItems = useCallback(() => {
|
|
const popper = tippyRef.current?.popper;
|
|
if (!popper) return [];
|
|
|
|
const menuContainer = popper.querySelector('[role="menu"]');
|
|
if (!menuContainer) return [];
|
|
|
|
return Array.from(
|
|
menuContainer.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')
|
|
);
|
|
}, []);
|
|
|
|
// Handle item click and close dropdown
|
|
const handleItemClick = useCallback((item) => {
|
|
if (item.disabled) return;
|
|
item.onClick?.();
|
|
setIsOpen(false);
|
|
}, []);
|
|
|
|
// Focus a menu item
|
|
const focusMenuItem = (item, addSelectedClass = false) => {
|
|
if (item) {
|
|
// Remove selected class from all items first
|
|
const menuContainer = item.closest('[role="menu"]');
|
|
if (menuContainer) {
|
|
menuContainer.querySelectorAll('.selected-focused').forEach((el) => {
|
|
el.classList.remove('selected-focused');
|
|
});
|
|
}
|
|
|
|
if (addSelectedClass) {
|
|
item.classList.add('selected-focused');
|
|
}
|
|
item.focus();
|
|
item.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
};
|
|
|
|
// Keyboard navigation handler (handles all keyboard events at menu level)
|
|
const handleMenuKeyDown = useCallback((e) => {
|
|
const itemsToNavigate = getMenuItems();
|
|
if (itemsToNavigate.length === 0) return;
|
|
|
|
const currentIndex = itemsToNavigate.findIndex((el) => el === document.activeElement);
|
|
const isNoMenuItemFocused = currentIndex === -1;
|
|
|
|
// Handle Escape
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
// Handle action keys (Enter, Space)
|
|
if (ACTION_KEYS.includes(e.key) && !isNoMenuItemFocused) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const currentItem = itemsToNavigate[currentIndex];
|
|
const itemId = currentItem?.getAttribute('data-item-id');
|
|
const item = items.find((i) => i.id === itemId);
|
|
if (item && !item.disabled) {
|
|
handleItemClick(item);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle navigation keys
|
|
if (NAVIGATION_KEYS.includes(e.key)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const nextIndex = getNextIndex(currentIndex, itemsToNavigate.length, e.key, isNoMenuItemFocused);
|
|
focusMenuItem(itemsToNavigate[nextIndex], false);
|
|
}
|
|
}, [getMenuItems, items, handleItemClick]);
|
|
|
|
// Toggle dropdown visibility
|
|
const handleTriggerClick = useCallback(() => {
|
|
setIsOpen((prev) => !prev);
|
|
}, []);
|
|
|
|
// Close dropdown when clicking outside
|
|
const handleClickOutside = useCallback(() => {
|
|
setIsOpen(false);
|
|
}, []);
|
|
|
|
// Setup Tippy instance
|
|
const onDropdownCreate = useCallback((ref) => {
|
|
tippyRef.current = ref;
|
|
if (ref) {
|
|
ref.setProps({
|
|
onShow: () => {
|
|
// Focus selected item if available, otherwise focus menu container
|
|
setTimeout(() => {
|
|
const menuContainer = ref.popper?.querySelector('[role="menu"]');
|
|
if (!menuContainer) return;
|
|
|
|
// If selectedItemId is provided, find and focus that item
|
|
if (selectedItemId) {
|
|
const menuItems = Array.from(
|
|
menuContainer.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')
|
|
);
|
|
|
|
const selectedItem = menuItems.find(
|
|
(item) => item.getAttribute('data-item-id') === selectedItemId
|
|
);
|
|
|
|
if (selectedItem) {
|
|
focusMenuItem(selectedItem, true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback: focus menu container
|
|
menuContainer.focus();
|
|
}, 0);
|
|
}
|
|
});
|
|
}
|
|
}, [selectedItemId]);
|
|
|
|
// Render section (left or right)
|
|
const renderSection = (section) => {
|
|
if (!section) return null;
|
|
|
|
// If it's a React component (function), render it with default icon props
|
|
if (typeof section === 'function') {
|
|
const SectionComponent = section;
|
|
return <SectionComponent size={16} stroke={1.5} className="dropdown-icon" aria-hidden="true" />;
|
|
}
|
|
|
|
// If it's already a React element, render it as-is
|
|
return section;
|
|
};
|
|
|
|
// Render menu item
|
|
const renderMenuItem = (item) => {
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className={`dropdown-item ${item.disabled ? 'disabled' : ''} ${item.className || ''}`.trim()}
|
|
role="menuitem"
|
|
data-item-id={item.id}
|
|
onClick={() => !item.disabled && handleItemClick(item)}
|
|
tabIndex={item.disabled ? -1 : 0}
|
|
aria-label={item.ariaLabel}
|
|
aria-disabled={item.disabled}
|
|
title={item.title}
|
|
data-testid={`${testId}-${item.id.toLowerCase()}`}
|
|
>
|
|
{renderSection(item.leftSection)}
|
|
<span className="dropdown-label">{item.label}</span>
|
|
{item.rightSection && (
|
|
<div
|
|
className="dropdown-right-section"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
{renderSection(item.rightSection)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Render label item
|
|
const renderLabel = (item) => (
|
|
<div key={item.id || `label-${item.label}`} className="label-item" role="presentation" data-testid={`${testId}-label-${item.label.toLowerCase().replace(/ /g, '-')}`}>
|
|
{item.label}
|
|
</div>
|
|
);
|
|
|
|
// Render divider item
|
|
const renderDivider = (item, index) => (
|
|
<div key={item.id || `divider-${index}`} className="dropdown-separator" role="separator" />
|
|
);
|
|
|
|
// Render menu content
|
|
const renderMenuContent = () => {
|
|
let dividerIndex = 0;
|
|
|
|
return items.map((item) => {
|
|
const itemType = item.type || 'item';
|
|
|
|
if (itemType === 'label') {
|
|
return renderLabel(item);
|
|
}
|
|
|
|
if (itemType === 'divider') {
|
|
return renderDivider(item, dividerIndex++);
|
|
}
|
|
|
|
return renderMenuItem(item);
|
|
});
|
|
};
|
|
|
|
// Clone children to attach click handler
|
|
const triggerElement = React.isValidElement(children)
|
|
? React.cloneElement(children, {
|
|
'onClick': (e) => {
|
|
children.props.onClick?.(e);
|
|
handleTriggerClick();
|
|
},
|
|
'data-testid': testId
|
|
})
|
|
: <div onClick={handleTriggerClick} data-testid={testId}>{children}</div>;
|
|
|
|
return (
|
|
<Dropdown
|
|
onCreate={onDropdownCreate}
|
|
icon={triggerElement}
|
|
placement={placement}
|
|
className={className}
|
|
visible={isOpen}
|
|
onClickOutside={handleClickOutside}
|
|
{...dropdownProps}
|
|
>
|
|
<div role="menu" tabIndex={-1} onKeyDown={handleMenuKeyDown}>
|
|
{renderMenuContent()}
|
|
</div>
|
|
</Dropdown>
|
|
);
|
|
});
|
|
|
|
export default MenuDropdown;
|