From 063a93abb611b834bd10a2c5c609e02ac913ee4c Mon Sep 17 00:00:00 2001 From: KJ Monahan Date: Fri, 23 May 2025 09:51:58 -0500 Subject: [PATCH 01/12] [16] Initial dropdown menu work --- .gitignore | 1 + .../Dropdown/Dropdown.stories.tsx | 41 ++ source/03-components/Dropdown/Dropdown.tsx | 652 ++++++++++++++++++ source/03-components/Dropdown/DropdownItem.ts | 16 + .../Dropdown/dropdown.module.css | 247 +++++++ source/03-components/Dropdown/dropdownArgs.ts | 160 +++++ 6 files changed, 1117 insertions(+) create mode 100644 source/03-components/Dropdown/Dropdown.stories.tsx create mode 100644 source/03-components/Dropdown/Dropdown.tsx create mode 100644 source/03-components/Dropdown/DropdownItem.ts create mode 100644 source/03-components/Dropdown/dropdown.module.css create mode 100644 source/03-components/Dropdown/dropdownArgs.ts diff --git a/.gitignore b/.gitignore index 7ab1f8dd..f3e54830 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ .DS_Store *.pem .idea +.junie # debug npm-debug.log* diff --git a/source/03-components/Dropdown/Dropdown.stories.tsx b/source/03-components/Dropdown/Dropdown.stories.tsx new file mode 100644 index 00000000..b4fe9baa --- /dev/null +++ b/source/03-components/Dropdown/Dropdown.stories.tsx @@ -0,0 +1,41 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { withGlobalWrapper } from '../../../.storybook/decorators'; +import DropdownComponent from './Dropdown'; +import dropdownArgs from './dropdownArgs'; + +const meta: Meta = { + title: 'Components/Dropdown', + component: DropdownComponent, + decorators: [withGlobalWrapper], + tags: ['autodocs'], +}; + +type Story = StoryObj; +const Dropdown: Story = { + args: dropdownArgs, + name: 'Navigation Menu from YAML', +}; + +const WithExpandedAbout: Story = { + args: { + ...dropdownArgs, + // Pre-expand the About section + expandedItems: { About: true }, + }, + name: 'Navigation Menu with Expanded About Section', +}; + +const WithNestedSubmenu: Story = { + args: { + ...dropdownArgs, + // Pre-expand the About section and the About Staff submenu + expandedItems: { + About: true, + 'About Staff': true, + }, + }, + name: 'Navigation Menu with Nested Submenu', +}; + +export default meta; +export { Dropdown, WithExpandedAbout, WithNestedSubmenu }; diff --git a/source/03-components/Dropdown/Dropdown.tsx b/source/03-components/Dropdown/Dropdown.tsx new file mode 100644 index 00000000..060524cf --- /dev/null +++ b/source/03-components/Dropdown/Dropdown.tsx @@ -0,0 +1,652 @@ +import clsx from 'clsx'; +import { JSX, useEffect, useRef, useState } from 'react'; +import DropdownItem from './DropdownItem'; +import styles from './dropdown.module.css'; + +export interface DropdownProps { + /** + * Array of items to display in the dropdown + */ + items: DropdownItem[]; + + /** + * Currently selected item + */ + selectedItem?: DropdownItem; + + /** + * Callback function when an item is selected + */ + onSelect?: (item: DropdownItem) => void; + + /** + * Additional CSS class name + */ + className?: string; + + /** + * Whether the dropdown is disabled + */ + disabled?: boolean; + + /** + * Pre-expanded items + */ + expandedItems?: Record; +} + +/** + * Dropdown menu component + */ +function Dropdown({ + items, + selectedItem, + onSelect, + className = '', + disabled = false, + expandedItems: initialExpandedItems, +}: DropdownProps): JSX.Element { + const [selected, setSelected] = useState( + selectedItem, + ); + const [expandedItems, setExpandedItems] = useState< + Record + >(initialExpandedItems || {}); + const dropdownRef = useRef(null); + + // Update selected item when prop changes + useEffect(() => { + setSelected(selectedItem); + }, [selectedItem]); + + // Function to close all open menus + const closeAllMenus = () => { + setExpandedItems({}); + }; + + // Helper function to check if an element is a descendant of another element + const isElementDescendantOf = (child: Node, parent: Node): boolean => { + let node = child.parentNode; + while (node !== null) { + if (node === parent) { + return true; + } + node = node.parentNode; + } + return false; + }; + + // Helper function to check if an item is a descendant of another item + const isDescendantOf = ( + childTitle: string, + parentTitle: string, + itemsToSearch: DropdownItem[], + ): boolean => { + for (const item of itemsToSearch) { + if (item.title === parentTitle && item.below) { + // Check if the child is a direct descendant + if (item.below.some(child => child.title === childTitle)) { + return true; + } + + // Check if the child is a descendant of any of the children + for (const child of item.below) { + if (child.below && isDescendantOf(childTitle, child.title, [child])) { + return true; + } + } + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + if (isDescendantOf(childTitle, parentTitle, item.below)) { + return true; + } + } + } + return false; + }; + + // Helper function to find the parent of an item + const findParentOf = ( + childTitle: string, + itemsToSearch: DropdownItem[], + currentPath: string[] = [], + ): string | null => { + for (const item of itemsToSearch) { + if (item.below) { + // Check if the child is a direct descendant + if (item.below.some(child => child.title === childTitle)) { + return item.title; + } + + // Check in the item's children + for (const child of item.below) { + const result = findParentOf( + childTitle, + [child], + [...currentPath, item.title], + ); + if (result) { + return result; + } + } + } + } + return null; + }; + + // Add event listeners for click outside, ESC key, and focus management only if a menu is open + useEffect(() => { + // Check if any menu is open + const isAnyMenuOpen = Object.values(expandedItems).some(Boolean); + + // Only add event listeners if a menu is open + if (isAnyMenuOpen) { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + closeAllMenus(); + } + }; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeAllMenus(); + } + }; + + const handleFocusOut = (event: FocusEvent) => { + // If the dropdown doesn't contain the element that lost focus, do nothing + if (!dropdownRef.current?.contains(event.target as Node)) { + return; + } + + // If the related target (element receiving focus) is null or outside the dropdown, close all menus + if ( + !event.relatedTarget || + !dropdownRef.current.contains(event.relatedTarget as Node) + ) { + closeAllMenus(); + return; + } + + // Find the menu item that lost focus + const menuItems = dropdownRef.current.querySelectorAll( + `.${styles.item}`, + ); + let lostFocusItem: Element | null = null; + let lostFocusItemTitle: string | null = null; + + Array.from(menuItems).some(item => { + if (item.contains(event.target as Node)) { + lostFocusItem = item; + // Try to extract the title from the data attribute or text content + const titleElement = item.querySelector('a, button'); + if (titleElement) { + lostFocusItemTitle = titleElement.textContent?.trim() || null; + } + return true; // Break the loop + } + return false; + }); + + // If we couldn't find the menu item that lost focus, do nothing + if (!lostFocusItem || !lostFocusItemTitle) { + return; + } + + // If the element receiving focus is a descendant of the menu item that lost focus, do nothing + if (isElementDescendantOf(event.relatedTarget as Node, lostFocusItem)) { + return; + } + + // Find the item that is receiving focus + let receivingFocusItem: Element | null = null; + let receivingFocusItemTitle: string | null = null; + + Array.from(menuItems).some(item => { + if (item.contains(event.relatedTarget as Node)) { + receivingFocusItem = item; + // Try to extract the title from the data attribute or text content + const titleElement = item.querySelector('a, button'); + if (titleElement) { + receivingFocusItemTitle = + titleElement.textContent?.trim() || null; + } + return true; // Break the loop + } + return false; + }); + + // If we couldn't find the item receiving focus, close all menus + if (!receivingFocusItem || !receivingFocusItemTitle) { + closeAllMenus(); + return; + } + + // Check if the receiving focus item is a direct parent of the lost focus item + const lostFocusParentTitle = findParentOf(lostFocusItemTitle, items); + + // If the receiving focus item is the direct parent of the lost focus item, + // or it's the toggle button for the parent, don't close the menu + if (lostFocusParentTitle === receivingFocusItemTitle) { + return; + } + + // Check if the receiving focus item is a top-level item + const isReceivingFocusTopLevel = items.some( + item => item.title === receivingFocusItemTitle, + ); + + // Check if the lost focus item is a top-level item + const isLostFocusTopLevel = items.some( + item => item.title === lostFocusItemTitle, + ); + + // If moving from a child item to a different top-level item, close all menus except the new one + if (isReceivingFocusTopLevel && !isLostFocusTopLevel) { + setExpandedItems(prev => { + const newExpandedItems: Record = {}; + // Only keep the receiving focus item expanded if it was already expanded + if (receivingFocusItemTitle && prev[receivingFocusItemTitle]) { + newExpandedItems[receivingFocusItemTitle] = true; + } + return newExpandedItems; + }); + return; + } + + // If moving between items at the same level that share the same parent, + // close the lost focus item's submenu but keep the parent menu open + if ( + lostFocusParentTitle && + lostFocusParentTitle === findParentOf(receivingFocusItemTitle, items) + ) { + setExpandedItems(prev => { + const newExpandedItems: Record = { ...prev }; + + // Only close the lost focus item if it's expanded + if (lostFocusItemTitle && newExpandedItems[lostFocusItemTitle]) { + newExpandedItems[lostFocusItemTitle] = false; + + // Also close any children of the lost focus item + const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { + for (const item of itemsToSearch) { + if (item.title === lostFocusItemTitle && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } + } + return false; // Item not found in this branch + }; + + findAndCloseChildren(items); + } + + return newExpandedItems; + }); + return; + } + + // If the receiving focus item is a sibling of the parent of the lost focus item, + // close the parent of the lost focus item and all its children + if (lostFocusParentTitle) { + // Find the parent of the parent of the lost focus item + const lostFocusGrandparentTitle = findParentOf( + lostFocusParentTitle, + items, + ); + + // Find the parent of the receiving focus item + const receivingFocusParentTitle = findParentOf( + receivingFocusItemTitle, + items, + ); + + // If they share the same parent (grandparent of lost focus item), + // then the receiving focus item is a sibling of the parent of the lost focus item + if ( + lostFocusGrandparentTitle && + lostFocusGrandparentTitle === receivingFocusParentTitle + ) { + setExpandedItems(prev => { + const newExpandedItems: Record = { ...prev }; + + // Close the parent of the lost focus item + if (newExpandedItems[lostFocusParentTitle]) { + newExpandedItems[lostFocusParentTitle] = false; + + // Also close any children of the parent of the lost focus item + const findAndCloseChildren = ( + itemsToSearch: DropdownItem[], + ) => { + for (const item of itemsToSearch) { + if (item.title === lostFocusParentTitle && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } + } + return false; // Item not found in this branch + }; + + findAndCloseChildren(items); + } + + return newExpandedItems; + }); + return; + } + } + + // For any other case, close the lost focus item's submenu + setExpandedItems(prev => { + const newExpandedItems: Record = { ...prev }; + + // Only close the lost focus item if it's expanded + if (lostFocusItemTitle && newExpandedItems[lostFocusItemTitle]) { + newExpandedItems[lostFocusItemTitle] = false; + + // Also close any children of the lost focus item + const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { + for (const item of itemsToSearch) { + if (item.title === lostFocusItemTitle && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } + } + return false; // Item not found in this branch + }; + + findAndCloseChildren(items); + } + + return newExpandedItems; + }); + }; + + // Add event listeners + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscKey); + document.addEventListener('focusout', handleFocusOut); + + // Clean up event listeners + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscKey); + document.removeEventListener('focusout', handleFocusOut); + }; + } + + // No cleanup needed if no listeners were added + return undefined; + }, [expandedItems]); + + const toggleExpandItem = (itemTitle: string, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + setExpandedItems(prev => { + const newExpandedItems = { ...prev }; + const isCurrentlyExpanded = prev[itemTitle] || false; + + // Toggle the current item + newExpandedItems[itemTitle] = !isCurrentlyExpanded; + + // If we're closing an item, also close all its children + if (isCurrentlyExpanded) { + // Find the item in the items array (or nested arrays) + const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { + for (const item of itemsToSearch) { + if (item.title === itemTitle && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } + } + return false; // Item not found in this branch + }; + + findAndCloseChildren(items); + } else { + // We're opening an item + // Find the parent of the current item + const parentTitle = findParentOf(itemTitle, items); + + // Close all other open items at the same level + Object.keys(prev).forEach(key => { + // Skip the current item + if (key === itemTitle) return; + + // Skip if the item is a parent of the current item + if (parentTitle && key === parentTitle) return; + + // Skip if the item is an ancestor of the current item + if (isDescendantOf(itemTitle, key, items)) return; + + // Skip if the current item is an ancestor of this item + if (isDescendantOf(key, itemTitle, items)) return; + + // If we're at the same level, close the other item + const keyParent = findParentOf(key, items); + if (keyParent === parentTitle) { + newExpandedItems[key] = false; + + // Also close all children of this item + const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { + for (const item of itemsToSearch) { + if (item.title === key && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } + } + return false; // Item not found in this branch + }; + + findAndCloseChildren(items); + } + }); + } + + return newExpandedItems; + }); + }; + + const handleSelect = (item: DropdownItem) => { + setSelected(item); + if (onSelect) { + onSelect(item); + } + }; + + const renderDropdownItem = (item: DropdownItem, isChild = false) => { + // Get properties from item + const itemTitle = item.title; + const itemUrl = item.url || ''; + const itemBelow = item.below || []; + const hasChildren = itemBelow.length > 0; + const isExpanded = expandedItems[itemTitle] || false; + const isActive = item.is_active || selected?.title === item.title || false; + const isInActiveTrail = item.in_active_trail || false; + + // For top-level items without children, render as links + if (!hasChildren && !isChild && itemUrl) { + return ( +
  • + + {itemTitle} + +
  • + ); + } + + // For top-level items with children, render as buttons + if (hasChildren && !isChild) { + return ( +
  • + + {isExpanded && itemBelow.length > 0 && ( +
      + {itemBelow.map(child => renderDropdownItem(child, true))} +
    + )} +
  • + ); + } + + // For child items with children, render as link + button + if (isChild && hasChildren) { + return ( +
  • +
    + + {itemTitle} + +
    + {isExpanded && itemBelow.length > 0 && ( +
      + {itemBelow.map(child => renderDropdownItem(child, true))} +
    + )} +
  • + ); + } + + // For child items without children, render as links + if (isChild && !hasChildren) { + return ( +
  • + + {itemTitle} + +
  • + ); + } + + // Fallback for any other case + return ( +
  • handleSelect(item)} + role="option" + aria-selected={isActive} + > + {itemTitle} +
  • + ); + }; + + return ( +
    +
      + {items.map(item => renderDropdownItem(item))} +
    +
    + ); +} + +export default Dropdown; diff --git a/source/03-components/Dropdown/DropdownItem.ts b/source/03-components/Dropdown/DropdownItem.ts new file mode 100644 index 00000000..afe58712 --- /dev/null +++ b/source/03-components/Dropdown/DropdownItem.ts @@ -0,0 +1,16 @@ +interface DropdownItem { + readonly title: string; + readonly url?: string; + readonly below?: DropdownItem[]; + readonly in_active_trail?: boolean; + readonly is_active?: boolean; + readonly original_link?: { + options: { + attributes: { + class: string; + }; + }; + }; +} + +export default DropdownItem; diff --git a/source/03-components/Dropdown/dropdown.module.css b/source/03-components/Dropdown/dropdown.module.css new file mode 100644 index 00000000..ed6b447d --- /dev/null +++ b/source/03-components/Dropdown/dropdown.module.css @@ -0,0 +1,247 @@ +@import 'mixins'; + +/* Submenu styles */ +.submenu { + @include list-clean; + + background-color: var(--ui-background-dark); + color: var(--text-on-dark); + display: none; + inset-block-start: 100%; /* Position below the parent item */ + inset-inline-start: 0; /* Align left edge with parent */ + list-style-type: none; + margin: var(--spacing-1) 0 0; + padding: 0; + position: absolute; +} + +.submenu .link { + color: currentColor; + display: block; + line-height: var(--line-height-short); + padding: var(--spacing-2) calc(var(--spacing-3) + 7px) var(--spacing-2) + var(--spacing-3); + position: relative; + text-decoration: none; + width: 200px; +} + +.submenu .link.has-subnav::after { + @include image-replace(16px, 16px); + + background-size: 16px 16px; + block-size: 16px; + content: ''; + display: block; + inline-size: 16px; + inset-block-start: 50%; + inset-inline-end: 5px; + overflow: hidden; + position: absolute; + transform: translateY(-50%); +} + +/* Basic elements */ +.item { + margin-block-end: 0; +} + +.item--has-children { + position: relative; +} + +.link.has-subnav { + position: relative; +} + +.item--selected > .link, +.item--active-trail > .link { + font-weight: var(--font-weight-bold); +} + +/* Main container */ +.dropdown { + @include list-inline; + + list-style-type: none; + margin-inline-start: 0; + padding-inline-start: 0; +} + +.dropdown--disabled { + opacity: 0.5; + pointer-events: none; +} + +.dropdown > .item { + display: inline-block; + position: relative; +} + +.dropdown > .item > .link { + background-color: transparent; + border: 0; + display: inline-block; + font-weight: var(--font-weight-semibold); + margin-inline-end: var(--spacing-5); + padding: 0; + position: relative; + text-decoration: none; + text-transform: uppercase; +} + +.dropdown > .item > .link.has-subnav { + padding-inline-end: 25px; +} + +.dropdown > .item > .link.has-subnav::after { + background-image: url('/images/menu-arrow-down'); + background-size: 16px 16px; + content: ''; + display: block; + height: 16px; + inset-block-start: 50%; + inset-inline-end: 5px; + overflow: hidden; + position: absolute; + transform: translateY(-40%); + width: 16px; +} + +.dropdown > .item > button.link { + @include text-button; + + appearance: none; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + color: var(--text-link); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +.dropdown > .item > button.link:visited { + color: var(--text-link-visited); +} + +.dropdown > .item > button.link:hover, +.dropdown > .item > button.link:focus { + color: var(--text-link-hover); +} + +.dropdown > .item > button.link:active { + color: var(--text-link-active); +} + +.dropdown > .item > button.link[disabled] { + color: var(--button-text-disabled); + cursor: default; + pointer-events: none; +} + +/* Toggle button */ +.toggle { + @include text-button; + + appearance: none; + background: transparent; + block-size: 16px; + border: 0; + border-radius: 0; + box-shadow: none; + cursor: pointer; + display: block; + font-family: inherit; + font-size: inherit; + inline-size: 16px; + inset-block-start: 50%; + inset-inline-end: 5px; + overflow: hidden; + padding: 0; + position: absolute; + -webkit-tap-highlight-color: transparent; + transform: translateY(-40%); +} + +.toggle::before { + block-size: 150%; + content: ''; + display: block; + inline-size: 0; +} + +.toggle svg { + pointer-events: none; +} + +.item--expanded > .submenu { + display: block; +} + +.submenu .item.has-subnav { + position: relative; +} + +.submenu .link:hover, +.submenu .link:focus { + background-color: var(--ui-background-dark); +} + +.submenu .link.has-subnav::before { + block-size: 150%; + content: ''; + display: block; + inline-size: 0; +} + +.submenu .subnav-toggle { + @include text-button; + + appearance: none; + background: transparent url('/images/menu-arrow-right.svg'); + background-size: var(--spacing-3) var(--spacing-3); + block-size: calc(var(--spacing-3) + 2px); + border: 1px solid currentColor; + border-radius: 0; + box-shadow: none; + color: inherit; + cursor: pointer; + display: block; + font-family: inherit; + font-size: inherit; + inline-size: calc(var(--spacing-3) + 2px); + inset-block-start: 50%; + inset-inline-end: 5px; + overflow: hidden; + padding: 0; + position: absolute; + -webkit-tap-highlight-color: transparent; + transform: translateY(-50%); +} + +.submenu .subnav-toggle::before { + block-size: 150%; + content: ''; + display: block; + inline-size: 0; +} + +.submenu .subnav-toggle:hover, +.submenu .subnav-toggle:focus-visible { + background-color: var(--ui-background-dark); +} + +.submenu .submenu { + margin: 0; +} + +/* Position nested submenus to the right of their parent with top edges aligned */ +.submenu--nested { + inset-block-start: 0; /* Align top edge with parent */ + inset-inline-start: 100%; /* Position to the right of parent */ + margin-inline-start: var(--spacing-1); /* Add a small margin for spacing */ +} diff --git a/source/03-components/Dropdown/dropdownArgs.ts b/source/03-components/Dropdown/dropdownArgs.ts new file mode 100644 index 00000000..2c2e5e2e --- /dev/null +++ b/source/03-components/Dropdown/dropdownArgs.ts @@ -0,0 +1,160 @@ +import { DropdownProps } from './Dropdown'; + +const dropdownArgs = { + items: [ + { + title: 'Home', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + { + title: 'About', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: true, + below: [ + { + title: 'About Staff', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: true, + is_active: true, + below: [ + { + title: 'Staff Leadership', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + { + title: 'Staff Directory', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + { + title: 'Staff Benefits', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + ], + }, + { + title: 'About History', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + { + title: 'About Locations', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + ], + }, + { + title: 'Resources', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + below: [ + { + title: 'Resource Library', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + { + title: 'Resource Downloads', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + ], + }, + { + title: 'Contact', + url: '#0', + original_link: { + options: { + attributes: { + class: '', + }, + }, + }, + in_active_trail: false, + }, + ], + disabled: false, +} satisfies Partial; + +export default dropdownArgs; From ee825b8cda7b7662d5c83e03c5300ffb8a14b40d Mon Sep 17 00:00:00 2001 From: KJ Monahan Date: Fri, 23 May 2025 11:20:37 -0500 Subject: [PATCH 02/12] [16] Initial keyboard functionality --- source/03-components/Dropdown/Dropdown.tsx | 359 ++++++++++++++------- 1 file changed, 249 insertions(+), 110 deletions(-) diff --git a/source/03-components/Dropdown/Dropdown.tsx b/source/03-components/Dropdown/Dropdown.tsx index 060524cf..f0d75862 100644 --- a/source/03-components/Dropdown/Dropdown.tsx +++ b/source/03-components/Dropdown/Dropdown.tsx @@ -33,6 +33,11 @@ export interface DropdownProps { * Pre-expanded items */ expandedItems?: Record; + + /** + * Whether to use arrow keys for navigation + */ + useArrowKeys?: boolean; } /** @@ -45,6 +50,7 @@ function Dropdown({ className = '', disabled = false, expandedItems: initialExpandedItems, + useArrowKeys = true, }: DropdownProps): JSX.Element { const [selected, setSelected] = useState( selectedItem, @@ -59,11 +65,6 @@ function Dropdown({ setSelected(selectedItem); }, [selectedItem]); - // Function to close all open menus - const closeAllMenus = () => { - setExpandedItems({}); - }; - // Helper function to check if an element is a descendant of another element const isElementDescendantOf = (child: Node, parent: Node): boolean => { let node = child.parentNode; @@ -136,12 +137,225 @@ function Dropdown({ return null; }; - // Add event listeners for click outside, ESC key, and focus management only if a menu is open + // Function to toggle expanding/collapsing an item + const toggleExpandItem = (itemTitle: string, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + setExpandedItems(prev => { + const newExpandedItems = { ...prev }; + const isCurrentlyExpanded = prev[itemTitle] || false; + + // Toggle the current item + newExpandedItems[itemTitle] = !isCurrentlyExpanded; + + // If we're closing an item, also close all its children + if (isCurrentlyExpanded) { + // Find the item in the items array (or nested arrays) + const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { + for (const item of itemsToSearch) { + if (item.title === itemTitle && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } + } + return false; // Item not found in this branch + }; + + findAndCloseChildren(items); + } else { + // We're opening an item + // Find the parent of the current item + const parentTitle = findParentOf(itemTitle, items); + + // Close all other open items at the same level + Object.keys(prev).forEach(key => { + // Skip the current item + if (key === itemTitle) return; + + // Skip if the item is a parent of the current item + if (parentTitle && key === parentTitle) return; + + // Skip if the item is an ancestor of the current item + if (isDescendantOf(itemTitle, key, items)) return; + + // Skip if the current item is an ancestor of this item + if (isDescendantOf(key, itemTitle, items)) return; + + // If we're at the same level, close the other item + const keyParent = findParentOf(key, items); + if (keyParent === parentTitle) { + newExpandedItems[key] = false; + + // Also close all children of this item + const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { + for (const item of itemsToSearch) { + if (item.title === key && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } + + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } + } + return false; // Item not found in this branch + }; + + findAndCloseChildren(items); + } + }); + } + + return newExpandedItems; + }); + }; + + // Function to close all open menus + const closeAllMenus = () => { + setExpandedItems({}); + }; + + // Add event listeners for click outside, ESC key, keyboard navigation, and focus management useEffect(() => { // Check if any menu is open const isAnyMenuOpen = Object.values(expandedItems).some(Boolean); - // Only add event listeners if a menu is open + // Add keyboard navigation event listener regardless of menu state + const handleKeyboardNavigation = (event: KeyboardEvent) => { + if (!useArrowKeys) return; + + // Get all focusable elements in the dropdown + if (!dropdownRef.current) return; + + const focusableElements = + dropdownRef.current.querySelectorAll('button, a'); + + if (focusableElements.length === 0) return; + + // Find the currently focused element + const focusedElement = document.activeElement as HTMLElement; + if (!focusedElement || !dropdownRef.current.contains(focusedElement)) { + return; + } + + // Find the index of the focused element + const focusedIndex = + Array.from(focusableElements).indexOf(focusedElement); + if (focusedIndex === -1) return; + + // Check if the focused element is a button + const isFocusedButton = focusedElement.tagName.toLowerCase() === 'button'; + + // Check if the button's dropdown is expanded + const isDropdownExpanded = + isFocusedButton && + focusedElement.getAttribute('aria-expanded') === 'true'; + + // Handle arrow keys + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + event.preventDefault(); + + // If focus is on a button and its dropdown is collapsed, first expand its submenu and then move focus to the first link + if (isFocusedButton && !isDropdownExpanded) { + // Get the title of the button + const buttonTitle = focusedElement.textContent?.trim(); + if (buttonTitle) { + // Expand the submenu by directly updating the expandedItems state + setExpandedItems(prev => { + const newExpandedItems = { ...prev }; + newExpandedItems[buttonTitle] = true; + return newExpandedItems; + }); + + // Find the first link in the dropdown + const buttonParent = focusedElement.closest('li'); + if (buttonParent) { + // Use setTimeout to allow the DOM to update after the state change + setTimeout(() => { + const firstLink = buttonParent.querySelector('ul a'); + if (firstLink) { + (firstLink as HTMLElement).focus(); + } + }, 0); + } + return; + } + } + + // If focus is on a button and its dropdown is expanded, moves focus to the first link in the dropdown + if (isFocusedButton && isDropdownExpanded) { + // Find the first link in the dropdown + const buttonParent = focusedElement.closest('li'); + if (buttonParent) { + const firstLink = buttonParent.querySelector('ul a'); + if (firstLink) { + (firstLink as HTMLElement).focus(); + return; + } + } + } + + // If focus is on a link, and it is not the last item, moves focus to the next item + if (!isFocusedButton && focusedIndex < focusableElements.length - 1) { + focusableElements[focusedIndex + 1].focus(); + } + } + + if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + event.preventDefault(); + + // If focus is on a button or link, and it is not the first item in its list, moves focus to the previous button or link + if (focusedIndex > 0) { + focusableElements[focusedIndex - 1].focus(); + } + } + + if (event.key === 'Home') { + event.preventDefault(); + + // If focus is on a button or link, and it is not the first item in its list, moves focus to the first button or link + if (focusableElements.length > 0) { + focusableElements[0].focus(); + } + } + + if (event.key === 'End') { + event.preventDefault(); + + // If focus is on a button or link, and it is not the last item in its list, moves focus to the last button or link + if (focusableElements.length > 0) { + focusableElements[focusableElements.length - 1].focus(); + } + } + }; + + // Add keyboard navigation event listener + document.addEventListener('keydown', handleKeyboardNavigation); + + // Only add other event listeners if a menu is open if (isAnyMenuOpen) { const handleClickOutside = (event: MouseEvent) => { if ( @@ -319,12 +533,30 @@ function Dropdown({ items, ); - // If they share the same parent (grandparent of lost focus item), - // then the receiving focus item is a sibling of the parent of the lost focus item - if ( + // Check if the receiving focus item is a sibling of the parent of the lost focus item + // This can happen in two ways: + + // Case 1: They share the same parent (grandparent of lost focus item) + const sharesParent = Boolean( lostFocusGrandparentTitle && - lostFocusGrandparentTitle === receivingFocusParentTitle - ) { + lostFocusGrandparentTitle === receivingFocusParentTitle, + ); + + // Case 2: The receiving focus item is a direct sibling of the parent of the lost focus item + // This is the case when tabbing from a third-level item to a second-level item + const directSiblingCheck = findParentOf( + receivingFocusItemTitle, + items, + ); + const isDirectSibling = Boolean( + lostFocusGrandparentTitle && + directSiblingCheck && + lostFocusGrandparentTitle === directSiblingCheck, + ); + + const isSiblingOfParent = sharesParent || isDirectSibling; + + if (isSiblingOfParent) { setExpandedItems(prev => { const newExpandedItems: Record = { ...prev }; @@ -419,104 +651,11 @@ function Dropdown({ }; } - // No cleanup needed if no listeners were added - return undefined; - }, [expandedItems]); - - const toggleExpandItem = (itemTitle: string, event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - setExpandedItems(prev => { - const newExpandedItems = { ...prev }; - const isCurrentlyExpanded = prev[itemTitle] || false; - - // Toggle the current item - newExpandedItems[itemTitle] = !isCurrentlyExpanded; - - // If we're closing an item, also close all its children - if (isCurrentlyExpanded) { - // Find the item in the items array (or nested arrays) - const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { - for (const item of itemsToSearch) { - if (item.title === itemTitle && item.below) { - // Close all direct children - item.below.forEach(child => { - newExpandedItems[child.title] = false; - // Recursively close any grandchildren - if (child.below && child.below.length > 0) { - findAndCloseChildren(child.below); - } - }); - return true; // Item found and processed - } - - // Check in the item's children - if (item.below && item.below.length > 0) { - const found = findAndCloseChildren(item.below); - if (found) return true; - } - } - return false; // Item not found in this branch - }; - - findAndCloseChildren(items); - } else { - // We're opening an item - // Find the parent of the current item - const parentTitle = findParentOf(itemTitle, items); - - // Close all other open items at the same level - Object.keys(prev).forEach(key => { - // Skip the current item - if (key === itemTitle) return; - - // Skip if the item is a parent of the current item - if (parentTitle && key === parentTitle) return; - - // Skip if the item is an ancestor of the current item - if (isDescendantOf(itemTitle, key, items)) return; - - // Skip if the current item is an ancestor of this item - if (isDescendantOf(key, itemTitle, items)) return; - - // If we're at the same level, close the other item - const keyParent = findParentOf(key, items); - if (keyParent === parentTitle) { - newExpandedItems[key] = false; - - // Also close all children of this item - const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { - for (const item of itemsToSearch) { - if (item.title === key && item.below) { - // Close all direct children - item.below.forEach(child => { - newExpandedItems[child.title] = false; - // Recursively close any grandchildren - if (child.below && child.below.length > 0) { - findAndCloseChildren(child.below); - } - }); - return true; // Item found and processed - } - - // Check in the item's children - if (item.below && item.below.length > 0) { - const found = findAndCloseChildren(item.below); - if (found) return true; - } - } - return false; // Item not found in this branch - }; - - findAndCloseChildren(items); - } - }); - } - - return newExpandedItems; - }); - }; + // Clean up keyboard navigation event listener + return () => { + document.removeEventListener('keydown', handleKeyboardNavigation); + }; + }, [expandedItems, useArrowKeys]); const handleSelect = (item: DropdownItem) => { setSelected(item); From 7389bed821c5579e36c4e9cceffb4c6c8078d43b Mon Sep 17 00:00:00 2001 From: KJ Monahan Date: Fri, 23 May 2025 15:45:52 -0500 Subject: [PATCH 03/12] [16] Maybe keyboard functionality is working? --- .../Dropdown/Dropdown.stories.tsx | 23 +- source/03-components/Dropdown/Dropdown.tsx | 919 +++++++----------- .../Dropdown/dropdown.module.css | 50 +- source/03-components/Dropdown/dropdownArgs.ts | 1 - 4 files changed, 396 insertions(+), 597 deletions(-) diff --git a/source/03-components/Dropdown/Dropdown.stories.tsx b/source/03-components/Dropdown/Dropdown.stories.tsx index b4fe9baa..bd16b13c 100644 --- a/source/03-components/Dropdown/Dropdown.stories.tsx +++ b/source/03-components/Dropdown/Dropdown.stories.tsx @@ -16,26 +16,5 @@ const Dropdown: Story = { name: 'Navigation Menu from YAML', }; -const WithExpandedAbout: Story = { - args: { - ...dropdownArgs, - // Pre-expand the About section - expandedItems: { About: true }, - }, - name: 'Navigation Menu with Expanded About Section', -}; - -const WithNestedSubmenu: Story = { - args: { - ...dropdownArgs, - // Pre-expand the About section and the About Staff submenu - expandedItems: { - About: true, - 'About Staff': true, - }, - }, - name: 'Navigation Menu with Nested Submenu', -}; - export default meta; -export { Dropdown, WithExpandedAbout, WithNestedSubmenu }; +export { Dropdown }; diff --git a/source/03-components/Dropdown/Dropdown.tsx b/source/03-components/Dropdown/Dropdown.tsx index f0d75862..b8763499 100644 --- a/source/03-components/Dropdown/Dropdown.tsx +++ b/source/03-components/Dropdown/Dropdown.tsx @@ -1,144 +1,123 @@ import clsx from 'clsx'; -import { JSX, useEffect, useRef, useState } from 'react'; +import { GessoComponent } from 'gesso'; +import { + FocusEventHandler, + JSX, + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { flushSync } from 'react-dom'; import DropdownItem from './DropdownItem'; import styles from './dropdown.module.css'; -export interface DropdownProps { +interface DropdownProps extends GessoComponent { /** * Array of items to display in the dropdown */ items: DropdownItem[]; - /** - * Currently selected item - */ - selectedItem?: DropdownItem; - - /** - * Callback function when an item is selected - */ - onSelect?: (item: DropdownItem) => void; - - /** - * Additional CSS class name - */ - className?: string; - - /** - * Whether the dropdown is disabled - */ - disabled?: boolean; - - /** - * Pre-expanded items - */ - expandedItems?: Record; - /** * Whether to use arrow keys for navigation */ useArrowKeys?: boolean; } -/** - * Dropdown menu component - */ -function Dropdown({ - items, - selectedItem, - onSelect, - className = '', - disabled = false, - expandedItems: initialExpandedItems, - useArrowKeys = true, -}: DropdownProps): JSX.Element { - const [selected, setSelected] = useState( - selectedItem, - ); - const [expandedItems, setExpandedItems] = useState< - Record - >(initialExpandedItems || {}); - const dropdownRef = useRef(null); - - // Update selected item when prop changes - useEffect(() => { - setSelected(selectedItem); - }, [selectedItem]); - - // Helper function to check if an element is a descendant of another element - const isElementDescendantOf = (child: Node, parent: Node): boolean => { - let node = child.parentNode; - while (node !== null) { - if (node === parent) { +// Helper function to check if an item is a descendant of another item +const isDescendantOf = ( + childTitle: string, + parentTitle: string, + itemsToSearch: DropdownItem[], +): boolean => { + for (const item of itemsToSearch) { + if (item.title === parentTitle && item.below) { + // Check if the child is a direct descendant + if (item.below.some(child => child.title === childTitle)) { return true; } - node = node.parentNode; - } - return false; - }; - // Helper function to check if an item is a descendant of another item - const isDescendantOf = ( - childTitle: string, - parentTitle: string, - itemsToSearch: DropdownItem[], - ): boolean => { - for (const item of itemsToSearch) { - if (item.title === parentTitle && item.below) { - // Check if the child is a direct descendant - if (item.below.some(child => child.title === childTitle)) { + // Check if the child is a descendant of any of the children + for (const child of item.below) { + if (child.below && isDescendantOf(childTitle, child.title, [child])) { return true; } + } + } - // Check if the child is a descendant of any of the children - for (const child of item.below) { - if (child.below && isDescendantOf(childTitle, child.title, [child])) { - return true; - } - } + // Check in the item's children + if (item.below && item.below.length > 0) { + if (isDescendantOf(childTitle, parentTitle, item.below)) { + return true; + } + } + } + return false; +}; + +// Helper function to find the parent of an item +const findParentOf = ( + childTitle: string, + itemsToSearch: DropdownItem[], + currentPath: string[] = [], +): string | null => { + for (const item of itemsToSearch) { + if (item.below) { + // Check if the child is a direct descendant + if (item.below.some(child => child.title === childTitle)) { + return item.title; } // Check in the item's children - if (item.below && item.below.length > 0) { - if (isDescendantOf(childTitle, parentTitle, item.below)) { - return true; + for (const child of item.below) { + const result = findParentOf( + childTitle, + [child], + [...currentPath, item.title], + ); + if (result) { + return result; } } } - return false; - }; + } + return null; +}; - // Helper function to find the parent of an item - const findParentOf = ( - childTitle: string, - itemsToSearch: DropdownItem[], - currentPath: string[] = [], - ): string | null => { - for (const item of itemsToSearch) { - if (item.below) { - // Check if the child is a direct descendant - if (item.below.some(child => child.title === childTitle)) { - return item.title; - } +/** + * Dropdown menu component + */ +function Dropdown({ + items, + modifierClasses, + useArrowKeys = true, +}: DropdownProps): JSX.Element { + const [expandedItems, setExpandedItems] = useState< + Record + >({}); + const [isDesktop, setIsDesktop] = useState(false); + const dropdownRef = useRef(null); - // Check in the item's children - for (const child of item.below) { - const result = findParentOf( - childTitle, - [child], - [...currentPath, item.title], - ); - if (result) { - return result; - } - } - } - } - return null; - }; + // Check if any menu is open + const isAnyMenuOpen = Object.values(expandedItems).some(Boolean); + + useEffect(() => { + const desktopMediaQuery = window.matchMedia('(width >= 64em)'); + const handleMediaQueryChange = ( + e: MediaQueryList | MediaQueryListEvent, + ) => { + setIsDesktop(e.matches); + }; + desktopMediaQuery.addEventListener('change', handleMediaQueryChange); + handleMediaQueryChange(desktopMediaQuery); + return () => { + desktopMediaQuery.removeEventListener('change', handleMediaQueryChange); + }; + }, []); // Function to toggle expanding/collapsing an item - const toggleExpandItem = (itemTitle: string, event: React.MouseEvent) => { + const toggleExpandItem = (itemTitle: string, event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -181,52 +160,61 @@ function Dropdown({ // Find the parent of the current item const parentTitle = findParentOf(itemTitle, items); - // Close all other open items at the same level - Object.keys(prev).forEach(key => { - // Skip the current item - if (key === itemTitle) return; - - // Skip if the item is a parent of the current item - if (parentTitle && key === parentTitle) return; - - // Skip if the item is an ancestor of the current item - if (isDescendantOf(itemTitle, key, items)) return; - - // Skip if the current item is an ancestor of this item - if (isDescendantOf(key, itemTitle, items)) return; - - // If we're at the same level, close the other item - const keyParent = findParentOf(key, items); - if (keyParent === parentTitle) { - newExpandedItems[key] = false; - - // Also close all children of this item - const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { - for (const item of itemsToSearch) { - if (item.title === key && item.below) { - // Close all direct children - item.below.forEach(child => { - newExpandedItems[child.title] = false; - // Recursively close any grandchildren - if (child.below && child.below.length > 0) { - findAndCloseChildren(child.below); - } - }); - return true; // Item found and processed - } + // On desktop, only allow one submenu to be open at a time + // On mobile, allow multiple submenus to be open simultaneously + if (isDesktop) { + // Close all other open items at the same level + Object.keys(prev).forEach(key => { + // Skip the current item + if (key === itemTitle) return; + + // Skip if the item is a parent of the current item + if (parentTitle && key === parentTitle) return; + + // Skip if the item is an ancestor of the current item + if (isDescendantOf(itemTitle, key, items)) return; + + // Skip if the current item is an ancestor of this item + if (isDescendantOf(key, itemTitle, items)) return; + + // If we're at the same level, close the other item + const keyParent = findParentOf(key, items); + // For top-level items (parentTitle is null), only close other items on desktop + // For nested items, close other items at the same level regardless of desktop/mobile + if ( + keyParent === parentTitle && + (parentTitle !== null || isDesktop) + ) { + newExpandedItems[key] = false; + + // Also close all children of this item + const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { + for (const item of itemsToSearch) { + if (item.title === key && item.below) { + // Close all direct children + item.below.forEach(child => { + newExpandedItems[child.title] = false; + // Recursively close any grandchildren + if (child.below && child.below.length > 0) { + findAndCloseChildren(child.below); + } + }); + return true; // Item found and processed + } - // Check in the item's children - if (item.below && item.below.length > 0) { - const found = findAndCloseChildren(item.below); - if (found) return true; + // Check in the item's children + if (item.below && item.below.length > 0) { + const found = findAndCloseChildren(item.below); + if (found) return true; + } } - } - return false; // Item not found in this branch - }; + return false; // Item not found in this branch + }; - findAndCloseChildren(items); - } - }); + findAndCloseChildren(items); + } + }); + } } return newExpandedItems; @@ -238,124 +226,207 @@ function Dropdown({ setExpandedItems({}); }; - // Add event listeners for click outside, ESC key, keyboard navigation, and focus management - useEffect(() => { - // Check if any menu is open - const isAnyMenuOpen = Object.values(expandedItems).some(Boolean); + /** + * Handles keyboard navigation using arrow keys. + * + * @param {React.KeyboardEvent} event - The keyboard event triggered by user interaction. + * + * Behavior: + * - Focus management is applied only if the currently active element is inside the dropdown menu. + * - Validates whether the active element is a button or a link within its hierarchical context (top-level or nested submenu). + * - Handles different navigation keys: + * - ArrowDown/ArrowRight: Moves focus to the next focusable element or handles submenu expansion. + * - ArrowUp/ArrowLeft: Moves focus to the previous focusable element within the menu. + * - Home: Moves focus to the first focusable element within the current menu level. + * - End: Moves focus to the last focusable element within the current menu level. + * - For dropdown buttons, ensures expanded state is managed and focuses on the first element within the submenu if applicable. + */ + const handleArrowKeysNavigation = (event: ReactKeyboardEvent) => { + if (!dropdownRef.current) return; - // Add keyboard navigation event listener regardless of menu state - const handleKeyboardNavigation = (event: KeyboardEvent) => { - if (!useArrowKeys) return; + // Find the currently focused element + const focusedElement = document.activeElement as HTMLElement; + if (!focusedElement || !dropdownRef.current.contains(focusedElement)) { + return; + } - // Get all focusable elements in the dropdown - if (!dropdownRef.current) return; + // Check if the focused element is a button + const isFocusedButton = focusedElement.tagName.toLowerCase() === 'button'; - const focusableElements = - dropdownRef.current.querySelectorAll('button, a'); + // Check if the button's dropdown is expanded + const isDropdownExpanded = + isFocusedButton && + focusedElement.getAttribute('aria-expanded') === 'true'; - if (focusableElements.length === 0) return; + const parentSubmenu = focusedElement.closest('ul'); - // Find the currently focused element - const focusedElement = document.activeElement as HTMLElement; - if (!focusedElement || !dropdownRef.current.contains(focusedElement)) { - return; - } + if (!parentSubmenu) return; + + // Determine if the focused element is in the top level or a nested level + const isTopLevel = parentSubmenu === dropdownRef.current; - // Find the index of the focused element - const focusedIndex = - Array.from(focusableElements).indexOf(focusedElement); - if (focusedIndex === -1) return; - - // Check if the focused element is a button - const isFocusedButton = focusedElement.tagName.toLowerCase() === 'button'; - - // Check if the button's dropdown is expanded - const isDropdownExpanded = - isFocusedButton && - focusedElement.getAttribute('aria-expanded') === 'true'; - - // Handle arrow keys - if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { - event.preventDefault(); - - // If focus is on a button and its dropdown is collapsed, first expand its submenu and then move focus to the first link - if (isFocusedButton && !isDropdownExpanded) { - // Get the title of the button - const buttonTitle = focusedElement.textContent?.trim(); - if (buttonTitle) { - // Expand the submenu by directly updating the expandedItems state + // For top level, get all focusable elements + // For nested levels, only get links if not a top-level item + const selector = isTopLevel ? 'button, a' : 'a'; + + // Get the appropriate focusable elements based on the level, but only within the current submenu + const focusableElements = parentSubmenu.querySelectorAll( + `:scope > li > :is(${selector})`, + ); + + if (focusableElements.length === 0) return; + + // Find the index of the focused element + const focusedIndex = Array.from(focusableElements).indexOf(focusedElement); + if (focusedIndex === -1) return; + + // Handle arrow keys + if (event.key === 'ArrowDown' && isFocusedButton) { + event.preventDefault(); + if (!isDropdownExpanded) { + // Get the title of the button + const buttonTitle = focusedElement.textContent?.trim(); + if (buttonTitle) { + // Expand the submenu by directly updating the expandedItems state + flushSync(() => setExpandedItems(prev => { const newExpandedItems = { ...prev }; newExpandedItems[buttonTitle] = true; return newExpandedItems; - }); - - // Find the first link in the dropdown - const buttonParent = focusedElement.closest('li'); - if (buttonParent) { - // Use setTimeout to allow the DOM to update after the state change - setTimeout(() => { - const firstLink = buttonParent.querySelector('ul a'); - if (firstLink) { - (firstLink as HTMLElement).focus(); - } - }, 0); - } - return; - } + }), + ); } - // If focus is on a button and its dropdown is expanded, moves focus to the first link in the dropdown - if (isFocusedButton && isDropdownExpanded) { - // Find the first link in the dropdown - const buttonParent = focusedElement.closest('li'); - if (buttonParent) { - const firstLink = buttonParent.querySelector('ul a'); - if (firstLink) { - (firstLink as HTMLElement).focus(); - return; - } + // Find the first link in the dropdown + const buttonParent = focusedElement.closest('li'); + if (buttonParent) { + const firstLink = buttonParent.querySelector( + ':scope > ul > li > a', + ); + if (firstLink) { + firstLink.focus(); } } - - // If focus is on a link, and it is not the last item, moves focus to the next item - if (!isFocusedButton && focusedIndex < focusableElements.length - 1) { - focusableElements[focusedIndex + 1].focus(); - } + return; + } + } else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + event.preventDefault(); + if (!isFocusedButton && focusedIndex < focusableElements.length - 1) { + focusableElements[focusedIndex + 1].focus(); + } + } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + event.preventDefault(); + if (focusedIndex > 0) { + focusableElements[focusedIndex - 1].focus(); + } + } else if (event.key === 'Home') { + event.preventDefault(); + if (focusableElements.length > 0) { + focusableElements[0].focus(); + } + } else if (event.key === 'End') { + event.preventDefault(); + if (focusableElements.length > 0) { + focusableElements[focusableElements.length - 1].focus(); } + } + }; - if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { - event.preventDefault(); + /** + * Handles the focus out event for dropdown menu items. + * + * Behavior: + * - If the dropdown container does not encompass the currentTarget (the element losing focus), the function does nothing. + * - If the related target (element receiving focus) is null or outside the dropdown boundaries, all menus are closed. + * - Ensures that focus transitions within the same menu or menu hierarchy do not cause unnecessary state changes. + * - Updates the `expandedItems` state based on whether the focus transition relates to valid menu hierarchies. + * + * @param {React.FocusEvent} event - The focus out event object. + */ + const handleFocusOut: FocusEventHandler< + HTMLAnchorElement | HTMLButtonElement + > = event => { + const { currentTarget, relatedTarget } = event; + // If the dropdown doesn't contain the element that lost focus, do nothing + if (!dropdownRef.current?.contains(currentTarget)) { + return; + } - // If focus is on a button or link, and it is not the first item in its list, moves focus to the previous button or link - if (focusedIndex > 0) { - focusableElements[focusedIndex - 1].focus(); - } - } + // If the related target (element receiving focus) is null or outside the dropdown, close all menus + if ( + !relatedTarget || + !dropdownRef.current.contains(relatedTarget as Element) + ) { + closeAllMenus(); + return; + } - if (event.key === 'Home') { - event.preventDefault(); + const lostFocusItem = currentTarget.closest('li'); + const lostFocusItemTitle = currentTarget.textContent?.trim() || null; - // If focus is on a button or link, and it is not the first item in its list, moves focus to the first button or link - if (focusableElements.length > 0) { - focusableElements[0].focus(); - } + // If we couldn't find the menu item that lost focus, do nothing + if (!lostFocusItem || !lostFocusItemTitle) { + return; + } + + // If the element receiving focus is a descendant of the menu item that lost focus, do nothing + if (lostFocusItem.contains(relatedTarget as Element)) { + return; + } + + const receivingFocusItem = relatedTarget.closest('li'); + if (!receivingFocusItem) { + closeAllMenus(); + return; + } + const receivingFocusTitleElement = receivingFocusItem.querySelector< + HTMLAnchorElement | HTMLButtonElement + >(`:scope > :is(a, button)`); + const receivingFocusItemTitle: string | null = + receivingFocusTitleElement?.textContent?.trim() || null; + if (!receivingFocusItemTitle) { + closeAllMenus(); + return; + } + + const allMenuItems = Array.from( + dropdownRef.current.querySelectorAll< + HTMLAnchorElement | HTMLButtonElement + >('a, button'), + ); + + const newExpandedItems = Object.entries(expandedItems).map(([key]) => { + const menuButtonOrLink = allMenuItems.find( + v => v?.textContent?.trim() === key, + ); + if (!menuButtonOrLink) { + return [key, false]; + } + const menuItem = menuButtonOrLink.closest('li'); + if (!menuItem) { + return [key, false]; } - if (event.key === 'End') { - event.preventDefault(); + // Keep the menu open if the element receiving focus is an ancestor of the menu item. + if (receivingFocusItem.contains(menuItem)) { + return [key, true]; + } - // If focus is on a button or link, and it is not the last item in its list, moves focus to the last button or link - if (focusableElements.length > 0) { - focusableElements[focusableElements.length - 1].focus(); - } + // Keep the menu open if the menu item is an ancestor of the element receiving focus. + if (menuItem.contains(receivingFocusItem)) { + return [key, true]; } - }; - // Add keyboard navigation event listener - document.addEventListener('keydown', handleKeyboardNavigation); + return [key, false]; + }); + + setExpandedItems(Object.fromEntries(newExpandedItems)); + }; - // Only add other event listeners if a menu is open + // Add event listeners for click outside, ESC key, keyboard navigation, and focus management + useEffect(() => { + if (!dropdownRef.current) return; + // Only add event listeners while a menu is open if (isAnyMenuOpen) { const handleClickOutside = (event: MouseEvent) => { if ( @@ -372,297 +443,15 @@ function Dropdown({ } }; - const handleFocusOut = (event: FocusEvent) => { - // If the dropdown doesn't contain the element that lost focus, do nothing - if (!dropdownRef.current?.contains(event.target as Node)) { - return; - } - - // If the related target (element receiving focus) is null or outside the dropdown, close all menus - if ( - !event.relatedTarget || - !dropdownRef.current.contains(event.relatedTarget as Node) - ) { - closeAllMenus(); - return; - } - - // Find the menu item that lost focus - const menuItems = dropdownRef.current.querySelectorAll( - `.${styles.item}`, - ); - let lostFocusItem: Element | null = null; - let lostFocusItemTitle: string | null = null; - - Array.from(menuItems).some(item => { - if (item.contains(event.target as Node)) { - lostFocusItem = item; - // Try to extract the title from the data attribute or text content - const titleElement = item.querySelector('a, button'); - if (titleElement) { - lostFocusItemTitle = titleElement.textContent?.trim() || null; - } - return true; // Break the loop - } - return false; - }); - - // If we couldn't find the menu item that lost focus, do nothing - if (!lostFocusItem || !lostFocusItemTitle) { - return; - } - - // If the element receiving focus is a descendant of the menu item that lost focus, do nothing - if (isElementDescendantOf(event.relatedTarget as Node, lostFocusItem)) { - return; - } - - // Find the item that is receiving focus - let receivingFocusItem: Element | null = null; - let receivingFocusItemTitle: string | null = null; - - Array.from(menuItems).some(item => { - if (item.contains(event.relatedTarget as Node)) { - receivingFocusItem = item; - // Try to extract the title from the data attribute or text content - const titleElement = item.querySelector('a, button'); - if (titleElement) { - receivingFocusItemTitle = - titleElement.textContent?.trim() || null; - } - return true; // Break the loop - } - return false; - }); - - // If we couldn't find the item receiving focus, close all menus - if (!receivingFocusItem || !receivingFocusItemTitle) { - closeAllMenus(); - return; - } - - // Check if the receiving focus item is a direct parent of the lost focus item - const lostFocusParentTitle = findParentOf(lostFocusItemTitle, items); - - // If the receiving focus item is the direct parent of the lost focus item, - // or it's the toggle button for the parent, don't close the menu - if (lostFocusParentTitle === receivingFocusItemTitle) { - return; - } - - // Check if the receiving focus item is a top-level item - const isReceivingFocusTopLevel = items.some( - item => item.title === receivingFocusItemTitle, - ); - - // Check if the lost focus item is a top-level item - const isLostFocusTopLevel = items.some( - item => item.title === lostFocusItemTitle, - ); - - // If moving from a child item to a different top-level item, close all menus except the new one - if (isReceivingFocusTopLevel && !isLostFocusTopLevel) { - setExpandedItems(prev => { - const newExpandedItems: Record = {}; - // Only keep the receiving focus item expanded if it was already expanded - if (receivingFocusItemTitle && prev[receivingFocusItemTitle]) { - newExpandedItems[receivingFocusItemTitle] = true; - } - return newExpandedItems; - }); - return; - } - - // If moving between items at the same level that share the same parent, - // close the lost focus item's submenu but keep the parent menu open - if ( - lostFocusParentTitle && - lostFocusParentTitle === findParentOf(receivingFocusItemTitle, items) - ) { - setExpandedItems(prev => { - const newExpandedItems: Record = { ...prev }; - - // Only close the lost focus item if it's expanded - if (lostFocusItemTitle && newExpandedItems[lostFocusItemTitle]) { - newExpandedItems[lostFocusItemTitle] = false; - - // Also close any children of the lost focus item - const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { - for (const item of itemsToSearch) { - if (item.title === lostFocusItemTitle && item.below) { - // Close all direct children - item.below.forEach(child => { - newExpandedItems[child.title] = false; - // Recursively close any grandchildren - if (child.below && child.below.length > 0) { - findAndCloseChildren(child.below); - } - }); - return true; // Item found and processed - } - - // Check in the item's children - if (item.below && item.below.length > 0) { - const found = findAndCloseChildren(item.below); - if (found) return true; - } - } - return false; // Item not found in this branch - }; - - findAndCloseChildren(items); - } - - return newExpandedItems; - }); - return; - } - - // If the receiving focus item is a sibling of the parent of the lost focus item, - // close the parent of the lost focus item and all its children - if (lostFocusParentTitle) { - // Find the parent of the parent of the lost focus item - const lostFocusGrandparentTitle = findParentOf( - lostFocusParentTitle, - items, - ); - - // Find the parent of the receiving focus item - const receivingFocusParentTitle = findParentOf( - receivingFocusItemTitle, - items, - ); - - // Check if the receiving focus item is a sibling of the parent of the lost focus item - // This can happen in two ways: - - // Case 1: They share the same parent (grandparent of lost focus item) - const sharesParent = Boolean( - lostFocusGrandparentTitle && - lostFocusGrandparentTitle === receivingFocusParentTitle, - ); - - // Case 2: The receiving focus item is a direct sibling of the parent of the lost focus item - // This is the case when tabbing from a third-level item to a second-level item - const directSiblingCheck = findParentOf( - receivingFocusItemTitle, - items, - ); - const isDirectSibling = Boolean( - lostFocusGrandparentTitle && - directSiblingCheck && - lostFocusGrandparentTitle === directSiblingCheck, - ); - - const isSiblingOfParent = sharesParent || isDirectSibling; - - if (isSiblingOfParent) { - setExpandedItems(prev => { - const newExpandedItems: Record = { ...prev }; - - // Close the parent of the lost focus item - if (newExpandedItems[lostFocusParentTitle]) { - newExpandedItems[lostFocusParentTitle] = false; - - // Also close any children of the parent of the lost focus item - const findAndCloseChildren = ( - itemsToSearch: DropdownItem[], - ) => { - for (const item of itemsToSearch) { - if (item.title === lostFocusParentTitle && item.below) { - // Close all direct children - item.below.forEach(child => { - newExpandedItems[child.title] = false; - // Recursively close any grandchildren - if (child.below && child.below.length > 0) { - findAndCloseChildren(child.below); - } - }); - return true; // Item found and processed - } - - // Check in the item's children - if (item.below && item.below.length > 0) { - const found = findAndCloseChildren(item.below); - if (found) return true; - } - } - return false; // Item not found in this branch - }; - - findAndCloseChildren(items); - } - - return newExpandedItems; - }); - return; - } - } - - // For any other case, close the lost focus item's submenu - setExpandedItems(prev => { - const newExpandedItems: Record = { ...prev }; - - // Only close the lost focus item if it's expanded - if (lostFocusItemTitle && newExpandedItems[lostFocusItemTitle]) { - newExpandedItems[lostFocusItemTitle] = false; - - // Also close any children of the lost focus item - const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { - for (const item of itemsToSearch) { - if (item.title === lostFocusItemTitle && item.below) { - // Close all direct children - item.below.forEach(child => { - newExpandedItems[child.title] = false; - // Recursively close any grandchildren - if (child.below && child.below.length > 0) { - findAndCloseChildren(child.below); - } - }); - return true; // Item found and processed - } - - // Check in the item's children - if (item.below && item.below.length > 0) { - const found = findAndCloseChildren(item.below); - if (found) return true; - } - } - return false; // Item not found in this branch - }; - - findAndCloseChildren(items); - } - - return newExpandedItems; - }); - }; - - // Add event listeners document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscKey); - document.addEventListener('focusout', handleFocusOut); - // Clean up event listeners return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscKey); - document.removeEventListener('focusout', handleFocusOut); }; } - - // Clean up keyboard navigation event listener - return () => { - document.removeEventListener('keydown', handleKeyboardNavigation); - }; - }, [expandedItems, useArrowKeys]); - - const handleSelect = (item: DropdownItem) => { - setSelected(item); - if (onSelect) { - onSelect(item); - } - }; + }, [isAnyMenuOpen]); const renderDropdownItem = (item: DropdownItem, isChild = false) => { // Get properties from item @@ -671,18 +460,18 @@ function Dropdown({ const itemBelow = item.below || []; const hasChildren = itemBelow.length > 0; const isExpanded = expandedItems[itemTitle] || false; - const isActive = item.is_active || selected?.title === item.title || false; const isInActiveTrail = item.in_active_trail || false; // For top-level items without children, render as links if (!hasChildren && !isChild && itemUrl) { return ( -
  • - +
  • + {itemTitle}
  • @@ -703,6 +492,8 @@ function Dropdown({ onClick={e => toggleExpandItem(itemTitle, e)} aria-expanded={isExpanded} aria-haspopup="true" + onKeyDown={useArrowKeys ? handleArrowKeysNavigation : undefined} + onBlur={handleFocusOut} > {itemTitle} @@ -720,24 +511,25 @@ function Dropdown({ return (
  • -
    - - {itemTitle} - -
    + + {itemTitle} + + - {isExpanded && itemBelow.length > 0 && ( + {isExpanded && hasChildren && (
      - {itemBelow.map(child => renderDropdownItem(child, true))} + {item.below.map(child => renderDropdownItem(child, true))}
    )}
  • @@ -510,29 +510,36 @@ function Dropdown({ if (isChild && hasChildren) { return (
  • - {itemTitle} + {title}
  • @@ -543,17 +550,18 @@ function Dropdown({ if (isChild && !hasChildren) { return (
  • - {itemTitle} + {title}
  • ); @@ -562,11 +570,12 @@ function Dropdown({ // Fallback for any other case return (
  • - {itemTitle} + {title}
  • ); }; From bd04c5f41b938428cfd710df19daa8e2693e281c Mon Sep 17 00:00:00 2001 From: KJ Monahan Date: Fri, 23 May 2025 16:16:49 -0500 Subject: [PATCH 05/12] [16] Replace reliance of titles with actual IDs --- source/03-components/Dropdown/Dropdown.tsx | 114 +++++++++--------- source/03-components/Dropdown/DropdownItem.ts | 1 + .../Dropdown/dropdown.module.css | 1 - source/03-components/Dropdown/dropdownArgs.ts | 12 ++ 4 files changed, 69 insertions(+), 59 deletions(-) diff --git a/source/03-components/Dropdown/Dropdown.tsx b/source/03-components/Dropdown/Dropdown.tsx index b1823385..f46241cb 100644 --- a/source/03-components/Dropdown/Dropdown.tsx +++ b/source/03-components/Dropdown/Dropdown.tsx @@ -27,20 +27,20 @@ interface DropdownProps extends GessoComponent { // Helper function to check if an item is a descendant of another item const isDescendantOf = ( - childTitle: string, - parentTitle: string, + childId: string | number, + parentId: string | number, itemsToSearch: DropdownItem[], ): boolean => { for (const item of itemsToSearch) { - if (item.title === parentTitle && item.below) { + if (item.id === parentId && item.below) { // Check if the child is a direct descendant - if (item.below.some(child => child.title === childTitle)) { + if (item.below.some(child => child.id === childId)) { return true; } // Check if the child is a descendant of any of the children for (const child of item.below) { - if (child.below && isDescendantOf(childTitle, child.title, [child])) { + if (child.below && isDescendantOf(childId, child.id, [child])) { return true; } } @@ -48,7 +48,7 @@ const isDescendantOf = ( // Check in the item's children if (item.below && item.below.length > 0) { - if (isDescendantOf(childTitle, parentTitle, item.below)) { + if (isDescendantOf(childId, parentId, item.below)) { return true; } } @@ -58,23 +58,23 @@ const isDescendantOf = ( // Helper function to find the parent of an item const findParentOf = ( - childTitle: string, + childId: string | number, itemsToSearch: DropdownItem[], - currentPath: string[] = [], -): string | null => { + currentPath: (string | number)[] = [], +): string | number | null => { for (const item of itemsToSearch) { if (item.below) { // Check if the child is a direct descendant - if (item.below.some(child => child.title === childTitle)) { - return item.title; + if (item.below.some(child => child.id === childId)) { + return item.id; } // Check in the item's children for (const child of item.below) { const result = findParentOf( - childTitle, + childId, [child], - [...currentPath, item.title], + [...currentPath, item.id], ); if (result) { return result; @@ -117,26 +117,29 @@ function Dropdown({ }, []); // Function to toggle expanding/collapsing an item - const toggleExpandItem = (itemTitle: string, event: ReactMouseEvent) => { + const toggleExpandItem = ( + itemId: string | number, + event: ReactMouseEvent, + ) => { event.preventDefault(); event.stopPropagation(); setExpandedItems(prev => { const newExpandedItems = { ...prev }; - const isCurrentlyExpanded = prev[itemTitle] || false; + const isCurrentlyExpanded = prev[itemId] || false; // Toggle the current item - newExpandedItems[itemTitle] = !isCurrentlyExpanded; + newExpandedItems[itemId] = !isCurrentlyExpanded; // If we're closing an item, also close all its children if (isCurrentlyExpanded) { // Find the item in the items array (or nested arrays) const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { for (const item of itemsToSearch) { - if (item.title === itemTitle && item.below) { + if (item.id === itemId && item.below) { // Close all direct children item.below.forEach(child => { - newExpandedItems[child.title] = false; + newExpandedItems[child.id] = false; // Recursively close any grandchildren if (child.below && child.below.length > 0) { findAndCloseChildren(child.below); @@ -158,7 +161,7 @@ function Dropdown({ } else { // We're opening an item // Find the parent of the current item - const parentTitle = findParentOf(itemTitle, items); + const parentId = findParentOf(itemId, items); // On desktop, only allow one submenu to be open at a time // On mobile, allow multiple submenus to be open simultaneously @@ -166,34 +169,31 @@ function Dropdown({ // Close all other open items at the same level Object.keys(prev).forEach(key => { // Skip the current item - if (key === itemTitle) return; + if (key === String(itemId)) return; // Skip if the item is a parent of the current item - if (parentTitle && key === parentTitle) return; + if (parentId && key === String(parentId)) return; // Skip if the item is an ancestor of the current item - if (isDescendantOf(itemTitle, key, items)) return; + if (isDescendantOf(itemId, key, items)) return; // Skip if the current item is an ancestor of this item - if (isDescendantOf(key, itemTitle, items)) return; + if (isDescendantOf(key, itemId, items)) return; // If we're at the same level, close the other item const keyParent = findParentOf(key, items); - // For top-level items (parentTitle is null), only close other items on desktop + // For top-level items (parentId is null), only close other items on desktop // For nested items, close other items at the same level regardless of desktop/mobile - if ( - keyParent === parentTitle && - (parentTitle !== null || isDesktop) - ) { + if (keyParent === parentId && (parentId !== null || isDesktop)) { newExpandedItems[key] = false; // Also close all children of this item const findAndCloseChildren = (itemsToSearch: DropdownItem[]) => { for (const item of itemsToSearch) { - if (item.title === key && item.below) { + if (item.id === key && item.below) { // Close all direct children item.below.forEach(child => { - newExpandedItems[child.title] = false; + newExpandedItems[child.id] = false; // Recursively close any grandchildren if (child.below && child.below.length > 0) { findAndCloseChildren(child.below); @@ -284,14 +284,14 @@ function Dropdown({ if (event.key === 'ArrowDown' && isFocusedButton) { event.preventDefault(); if (!isDropdownExpanded) { - // Get the title of the button - const buttonTitle = focusedElement.textContent?.trim(); - if (buttonTitle) { + // Get the id of the button from the data attribute + const buttonId = focusedElement.getAttribute('data-id'); + if (buttonId) { // Expand the submenu by directly updating the expandedItems state flushSync(() => setExpandedItems(prev => { const newExpandedItems = { ...prev }; - newExpandedItems[buttonTitle] = true; + newExpandedItems[buttonId] = true; return newExpandedItems; }), ); @@ -362,10 +362,10 @@ function Dropdown({ } const lostFocusItem = currentTarget.closest('li'); - const lostFocusItemTitle = currentTarget.textContent?.trim() || null; + const lostFocusItemId = currentTarget.getAttribute('data-id') || null; // If we couldn't find the menu item that lost focus, do nothing - if (!lostFocusItem || !lostFocusItemTitle) { + if (!lostFocusItem || !lostFocusItemId) { return; } @@ -379,25 +379,19 @@ function Dropdown({ closeAllMenus(); return; } - const receivingFocusTitleElement = receivingFocusItem.querySelector< + const receivingFocusElement = receivingFocusItem.querySelector< HTMLAnchorElement | HTMLButtonElement >(`:scope > :is(a, button)`); - const receivingFocusItemTitle: string | null = - receivingFocusTitleElement?.textContent?.trim() || null; - if (!receivingFocusItemTitle) { + const receivingFocusItemId: string | null = + receivingFocusElement?.getAttribute('data-id') || null; + if (!receivingFocusItemId) { closeAllMenus(); return; } - const allMenuItems = Array.from( - dropdownRef.current.querySelectorAll< - HTMLAnchorElement | HTMLButtonElement - >('a, button'), - ); - const newExpandedItems = Object.entries(expandedItems).map(([key]) => { - const menuButtonOrLink = allMenuItems.find( - v => v?.textContent?.trim() === key, + const menuButtonOrLink = dropdownRef.current?.querySelector( + `[data-id="${key}"]`, ); if (!menuButtonOrLink) { return [key, false]; @@ -454,19 +448,20 @@ function Dropdown({ }, [isAnyMenuOpen]); const renderDropdownItem = (item: DropdownItem, isChild = false) => { - const { title, url } = item; + const { id, title, url } = item; const hasChildren = item.below?.length && item.below.length > 0; - const isExpanded = expandedItems[item.title] || false; + const isExpanded = expandedItems[item.id] || false; const isInActiveTrail = item.in_active_trail || false; // For top-level items without children, render as links if (!hasChildren && !isChild && url) { return ( -
  • +
  • @@ -480,7 +475,7 @@ function Dropdown({ if (hasChildren && !isChild) { return (
  • toggleExpandItem(title, e)} + onClick={e => toggleExpandItem(id, e)} aria-expanded={isExpanded} aria-haspopup="true" + data-id={id} onKeyDown={useArrowKeys ? handleArrowKeysNavigation : undefined} onBlur={handleFocusOut} > @@ -510,7 +506,7 @@ function Dropdown({ if (isChild && hasChildren) { return (
  • @@ -532,13 +529,13 @@ function Dropdown({ - {isExpanded && hasChildren && ( -
      - {item.below.map(child => renderDropdownItem(child, true))} -
    - )} -
  • - ); - } - - // For child items with children, render as link + button - if (isChild && hasChildren) { - return ( -
  • - - {title} - -
  • - ); - } - - // For child items without children, render as links - if (isChild && !hasChildren) { - return ( -
  • - - {title} - -
  • - ); - } - - // Fallback for any other case - return ( -
  • - {title} -
  • - ); - }; - return ( - + + + ); } export default Dropdown; -export type { DropdownProps }; +export type { DropdownMenuItem, DropdownProps }; diff --git a/source/03-components/Dropdown/DropdownContext.tsx b/source/03-components/Dropdown/DropdownContext.tsx new file mode 100644 index 00000000..179243dd --- /dev/null +++ b/source/03-components/Dropdown/DropdownContext.tsx @@ -0,0 +1,6 @@ +import { Context, createContext } from 'react'; + +const DropdownContext: Context> = + createContext({}); + +export default DropdownContext; diff --git a/source/03-components/Dropdown/DropdownItem.ts b/source/03-components/Dropdown/DropdownItem.ts deleted file mode 100644 index b2ef8d6c..00000000 --- a/source/03-components/Dropdown/DropdownItem.ts +++ /dev/null @@ -1,17 +0,0 @@ -interface DropdownItem { - readonly id: string | number; - readonly title: string; - readonly url?: string; - readonly below?: DropdownItem[]; - readonly in_active_trail?: boolean; - readonly is_active?: boolean; - readonly original_link?: { - options: { - attributes: { - class: string; - }; - }; - }; -} - -export default DropdownItem; diff --git a/source/03-components/Dropdown/DropdownItem.tsx b/source/03-components/Dropdown/DropdownItem.tsx new file mode 100644 index 00000000..ba6ee723 --- /dev/null +++ b/source/03-components/Dropdown/DropdownItem.tsx @@ -0,0 +1,354 @@ +import DropdownContext from '@/source/03-components/Dropdown/DropdownContext'; +import clsx from 'clsx'; +import { GessoComponent } from 'gesso'; +import { + FocusEventHandler, + JSX, + KeyboardEventHandler, + PropsWithChildren, + MouseEvent as ReactMouseEvent, + useContext, +} from 'react'; +import type { DropdownMenuItem } from './Dropdown'; +import styles from './dropdown.module.css'; + +interface DropdownItemProps extends GessoComponent { + item: DropdownMenuItem; + isChild?: boolean; + isExpanded: boolean; + useArrowKeys?: boolean; + onItemClick: (itemId: string | number, event: ReactMouseEvent) => void; + onKeyDown?: KeyboardEventHandler; + onBlur: FocusEventHandler; +} + +interface DropdownItemWrapperProps + extends Pick { + hasChildren?: boolean; + isInActiveTrail?: boolean; +} + +function DropdownItemWrapper({ + modifierClasses, + hasChildren, + isExpanded, + isInActiveTrail, + children, +}: PropsWithChildren): JSX.Element { + return ( +
  • + {children} +
  • + ); +} + +type DropdownLinkProps = Pick< + DropdownItemProps, + 'useArrowKeys' | 'onKeyDown' | 'onBlur' +> & + Pick; + +interface DropdownMenubarLinkProps + extends DropdownItemWrapperProps, + DropdownLinkProps {} + +function DropdownMenubarLink({ + modifierClasses, + useArrowKeys, + onKeyDown, + onBlur, + url, + id, + title, + isExpanded, +}: DropdownMenubarLinkProps): JSX.Element { + return ( + + + {title} + + + ); +} + +type DropdownMenubarButtonProps = DropdownItemWrapperProps & + Pick & + Pick< + DropdownItemProps, + 'useArrowKeys' | 'onKeyDown' | 'onBlur' | 'onItemClick' + >; + +function DropdownMenuBarButton({ + modifierClasses, + isExpanded, + id, + useArrowKeys, + onKeyDown, + onBlur, + title, + isInActiveTrail, + onItemClick, + below, +}: DropdownMenubarButtonProps): JSX.Element { + const expandedItems = useContext(DropdownContext); + + return ( + + + {isExpanded && below?.length && ( +
      + {below.map(child => ( + // eslint-disable-next-line @typescript-eslint/no-use-before-define + + ))} +
    + )} +
    + ); +} + +type PopupMenuLinkProps = DropdownLinkProps & DropdownItemWrapperProps; + +function PopupMenuLink({ + url, + hasChildren, + id, + useArrowKeys, + onKeyDown, + onBlur, + title, +}: PopupMenuLinkProps): JSX.Element { + return ( + + {title} + + ); +} + +type PopupMenuWithSubmenuProps = DropdownItemWrapperProps & + PopupMenuLinkProps & + DropdownMenubarButtonProps; + +function PopupMenuWithSubmenu({ + id, + modifierClasses, + isExpanded, + url, + onBlur, + onKeyDown, + useArrowKeys, + title, + onItemClick, + below, + isInActiveTrail, +}: PopupMenuWithSubmenuProps): JSX.Element { + const expandedItems = useContext(DropdownContext); + + return ( + + +