Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,29 +38,44 @@ export const NestedSecondaryMenu: NestedSecondaryMenuComponent = ({
const [panelStack, setPanelStack] = useState<Array<{ id: string; returnFocusId?: string }>>([]);
const [returnFocusId, setReturnFocusId] = useState<string | undefined>();

console.log(
`*** [NestedSecondaryMenu] render - currentPanel="${currentPanel}", panelStackDepth=${panelStack.length}`
);

const goToPanel = useCallback(
(panelId: string, focusId?: string) => {
setPanelStack((prev) => [
...prev,
{ id: currentPanel, returnFocusId: focusId || returnFocusId },
]);
console.log(
`*** [NestedSecondaryMenu] goToPanel() - from="${currentPanel}" to="${panelId}", focusId="${focusId}"`
);
setPanelStack((prev) => {
const newStack = [...prev, { id: currentPanel, returnFocusId: focusId || returnFocusId }];
console.log(`*** [NestedSecondaryMenu] goToPanel() - new stack depth=${newStack.length}`);
return newStack;
});
setCurrentPanel(panelId);
setReturnFocusId(undefined);
},
[currentPanel, returnFocusId]
);

const goBack = useCallback(() => {
console.log(`*** [NestedSecondaryMenu] goBack() - from="${currentPanel}"`);
setPanelStack((prev) => {
const previousPanel = prev[prev.length - 1];
if (!previousPanel) return prev;
if (!previousPanel) {
console.log(
`*** [NestedSecondaryMenu] goBack() - no previous panel, staying at "${currentPanel}"`
);
return prev;
}

console.log(`*** [NestedSecondaryMenu] goBack() - to="${previousPanel.id}"`);
setCurrentPanel(previousPanel.id);
setReturnFocusId(previousPanel.returnFocusId);

return prev.slice(0, -1);
});
}, []);
}, [currentPanel]);

const contextValue = {
canGoBack: panelStack.length > 0,
Expand Down
157 changes: 136 additions & 21 deletions src/core/packages/chrome/navigation/src/components/popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,74 +84,146 @@ export const SideNavPopover = ({
const [isOpenedByClick, setIsOpenedByClick] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [shouldFocusOnOpen, setShouldFocusOnOpen] = useState(false);
const [wasKeyboardUsed, setWasKeyboardUsed] = useState(false);

const setOpenedByClick = useCallback(() => setIsOpenedByClick(true), []);
const setOpenedByClick = useCallback(() => {
console.log(`*** [Popover:${label}] setOpenedByClick()`);
setIsOpenedByClick(true);
}, [label]);

const clearOpenedByClick = useCallback(() => setIsOpenedByClick(false), []);
const clearOpenedByClick = useCallback(() => {
console.log(`*** [Popover:${label}] clearOpenedByClick()`);
setIsOpenedByClick(false);
}, [label]);

const open = useCallback(() => {
console.log(`*** [Popover:${label}] open() - setting isOpen=true, anyPopoverOpen=true`);
console.log(
`*** [Popover:${label}] open() - isSidePanelOpen=${isSidePanelOpen}, previous anyPopoverOpen=${anyPopoverOpen}`
);
setIsOpen(true);
anyPopoverOpen = true;
}, []);
}, [label, isSidePanelOpen]);

const close = useCallback(() => {
console.log(`*** [Popover:${label}] close() - setting isOpen=false, anyPopoverOpen=false`);
console.log(`*** [Popover:${label}] close() - previous anyPopoverOpen=${anyPopoverOpen}`);
setIsOpen(false);
clearOpenedByClick();
clearTimeout();
setShouldFocusOnOpen(false);
anyPopoverOpen = false;
}, [clearOpenedByClick, clearTimeout]);
}, [clearOpenedByClick, clearTimeout, label]);

const handleClose = useCallback(() => {
console.log(`*** [Popover:${label}] handleClose()`);
clearTimeout();
close();
}, [clearTimeout, close]);
}, [clearTimeout, close, label]);

const tryOpen = useCallback(() => {
console.log(
`*** [Popover:${label}] tryOpen() - isSidePanelOpen=${isSidePanelOpen}, anyPopoverOpen=${getIsAnyPopoverOpenNow()}`
);
if (!isSidePanelOpen && !getIsAnyPopoverOpenNow()) {
console.log(`*** [Popover:${label}] tryOpen() - conditions met, calling open()`);
open();
} else {
console.log(`*** [Popover:${label}] tryOpen() - conditions NOT met, skipping open`);
}
}, [isSidePanelOpen, open]);
}, [isSidePanelOpen, open, label]);

const handleMouseEnter = useCallback(() => {
console.log(
`*** [Popover:${label}] handleMouseEnter() - persistent=${persistent}, isOpenedByClick=${isOpenedByClick}`
);
if (!persistent || !isOpenedByClick) {
clearTimeout();
if (getIsAnyPopoverOpenNow()) {
console.log(
`*** [Popover:${label}] handleMouseEnter() - another popover open, scheduling tryOpen`
);
setTimeout(tryOpen, POPOVER_HOVER_DELAY);
} else if (!isSidePanelOpen) {
console.log(`*** [Popover:${label}] handleMouseEnter() - no popover open, scheduling open`);
setTimeout(open, POPOVER_HOVER_DELAY);
} else {
console.log(`*** [Popover:${label}] handleMouseEnter() - side panel open, not opening`);
}
} else {
console.log(
`*** [Popover:${label}] handleMouseEnter() - persistent and opened by click, ignoring`
);
}
}, [persistent, isOpenedByClick, isSidePanelOpen, clearTimeout, open, setTimeout, tryOpen]);
}, [
persistent,
isOpenedByClick,
isSidePanelOpen,
clearTimeout,
open,
setTimeout,
tryOpen,
label,
]);

const handleMouseLeave = useCallback(() => {
console.log(
`*** [Popover:${label}] handleMouseLeave() - persistent=${persistent}, isOpenedByClick=${isOpenedByClick}`
);
if (!persistent || !isOpenedByClick) {
console.log(`*** [Popover:${label}] handleMouseLeave() - scheduling close`);
setTimeout(handleClose, POPOVER_HOVER_DELAY);
} else {
console.log(
`*** [Popover:${label}] handleMouseLeave() - persistent and opened by click, not closing`
);
}
}, [persistent, isOpenedByClick, setTimeout, handleClose]);
}, [persistent, isOpenedByClick, setTimeout, handleClose, label]);

const scrollStyles = useScroll(true);

const handleTriggerClick = useCallback(() => {
console.log(
`*** [Popover:${label}] handleTriggerClick() - persistent=${persistent}, isOpen=${isOpen}, isOpenedByClick=${isOpenedByClick}`
);
if (persistent) {
if (isOpen && isOpenedByClick) {
console.log(`*** [Popover:${label}] handleTriggerClick() - closing persistent popover`);
handleClose();
} else {
console.log(`*** [Popover:${label}] handleTriggerClick() - opening persistent popover`);
clearTimeout();
open();
setOpenedByClick();
}
} else {
console.log(`*** [Popover:${label}] handleTriggerClick() - not persistent, ignoring click`);
}
}, [persistent, isOpen, isOpenedByClick, handleClose, clearTimeout, open, setOpenedByClick]);
}, [
persistent,
isOpen,
isOpenedByClick,
handleClose,
clearTimeout,
open,
setOpenedByClick,
label,
]);

const handleTriggerKeyDown: KeyboardEventHandler = useCallback(
(e) => {
console.log(
`*** [Popover:${label}] handleTriggerKeyDown() - key=${e.key}, hasContent=${hasContent}`
);
if (e.key === 'Enter' || e.key === ' ') {
trigger.props.onKeyDown?.(e);

if (hasContent) {
// Required for entering the popover with Enter or Space key
// Otherwise the navigation happens immediately
console.log(
`*** [Popover:${label}] handleTriggerKeyDown() - opening with keyboard, will focus`
);
e.preventDefault();
setShouldFocusOnOpen(true);
open();
Expand All @@ -160,18 +232,21 @@ export const SideNavPopover = ({
trigger.props.onKeyDown?.(e);
}
},
[trigger, hasContent, open]
[trigger, hasContent, open, label]
);

const handlePopoverKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
(e) => {
console.log(`*** [Popover:${label}] handlePopoverKeyDown() - key=${e.key}`);
if (e.key === 'Escape') {
console.log(`*** [Popover:${label}] handlePopoverKeyDown() - Escape pressed, closing`);
handleClose();
triggerRef.current?.focus();
return;
}

if (e.key === 'Tab') {
console.log(`*** [Popover:${label}] handlePopoverKeyDown() - Tab pressed, closing`);
e.preventDefault();
handleClose();
focusAdjacentTrigger(triggerRef, e.shiftKey ? -1 : 1);
Expand All @@ -180,32 +255,67 @@ export const SideNavPopover = ({

handleRovingIndex(e);
},
[handleClose]
[handleClose, label]
);

const handleBlur: FocusEventHandler = useCallback(
(e) => {
console.log(`*** [Popover:${label}] handleBlur() - wasKeyboardUsed=${wasKeyboardUsed}`);
if (!wasKeyboardUsed) {
console.log(`*** [Popover:${label}] handleBlur() - keyboard not used, ignoring`);
return;
}

clearTimeout();

const nextFocused = e.relatedTarget;
const isStayingInComponent =
nextFocused &&
(triggerRef.current?.contains(nextFocused) || popoverRef.current?.contains(nextFocused));

console.log(
`*** [Popover:${label}] handleBlur() - isStayingInComponent=${isStayingInComponent}`
);
if (!isStayingInComponent) {
console.log(`*** [Popover:${label}] handleBlur() - leaving component, closing`);
handleClose();
}
},
[clearTimeout, handleClose]
[clearTimeout, handleClose, wasKeyboardUsed, label]
);

// Clean up on unmount
useEffect(() => {
console.log(`*** [Popover:${label}] mounted`);
return () => {
console.log(`*** [Popover:${label}] unmounting - cleaning up`);
clearTimeout();
handleClose();
};
}, [clearTimeout, handleClose]);
}, [clearTimeout, handleClose, label]);

// TODO: refactor to use non-portalled popover
// Track if the user has used the keyboard to interact with the popover.
// `wasKeyboardUsed` is used to determine if the popover should be closed when the user blurs the popover.
// If we blur it for mouse users as well, the popover isn't responsive when there are trap focus elements
// on the page.
useEffect(() => {
const handleKeyDown = () => {
console.log(`*** [Popover:${label}] keyboard used, setting wasKeyboardUsed=true`);
setWasKeyboardUsed(true);
};
const handleMouseDown = () => {
console.log(`*** [Popover:${label}] mouse used, setting wasKeyboardUsed=false`);
setWasKeyboardUsed(false);
};

window.addEventListener('keydown', handleKeyDown);
window.addEventListener('mousedown', handleMouseDown);

return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousedown', handleMouseDown);
};
}, [label]);

const enhancedTrigger = useMemo(
() =>
Expand Down Expand Up @@ -239,6 +349,11 @@ export const SideNavPopover = ({
z-index: calc(${euiTheme.levels.menu} - 1);
`;

const euiPopoverIsOpen = hasContent && !isSidePanelOpen && isOpen;
console.log(
`*** [Popover:${label}] render - EuiPopover isOpen=${euiPopoverIsOpen} (hasContent=${hasContent}, isSidePanelOpen=${isSidePanelOpen}, isOpen=${isOpen})`
);

return (
<div
css={wrapperStyles}
Expand All @@ -248,30 +363,30 @@ export const SideNavPopover = ({
onBlur={handleBlur}
>
<EuiPopover
aria-label={label}
anchorPosition="rightUp"
aria-label={label}
buffer={[TOP_BAR_HEIGHT + TOP_BAR_POPOVER_GAP, 0, BOTTOM_POPOVER_GAP, POPOVER_OFFSET]}
button={enhancedTrigger}
closePopover={handleClose}
container={container}
display="block"
hasArrow={false}
isOpen={hasContent && !isSidePanelOpen && isOpen}
isOpen={euiPopoverIsOpen}
offset={POPOVER_OFFSET}
ownFocus={false}
panelPaddingSize="none"
repositionOnScroll
>
<div
ref={(ref) => {
popoverRef.current = ref;
ref={(node) => {
popoverRef.current = node;

if (ref) {
const elements = getFocusableElements(ref);
if (node) {
const elements = getFocusableElements(node);
updateTabIndices(elements);

if (shouldFocusOnOpen) {
focusFirstElement(popoverRef);
focusFirstElement(node);
setShouldFocusOnOpen(false);
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/core/packages/chrome/navigation/src/hooks/use_navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ export const useNavigation = (
const openerNode = primaryItem;
const isSidePanelOpen = !isCollapsed && !!openerNode?.sections;

console.log(`*** [useNavigation] isCollapsed=${isCollapsed}, isSidePanelOpen=${isSidePanelOpen}`);
console.log(
`*** [useNavigation] openerNode=${
openerNode?.id || 'null'
}, hasSection=${!!openerNode?.sections}`
);
console.log(
`*** [useNavigation] activeItemId="${activeItemId}", visuallyActivePageId="${visuallyActivePageId}"`
);

const state: NavigationState = {
actualActiveItemId,
visuallyActivePageId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,17 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { RefObject } from 'react';

import { getFocusableElements } from './get_focusable_elements';

/**
* Utility function for focusing the first interactive element.
*
* @param ref - The ref to the container element.
*/
export const focusFirstElement = (ref: RefObject<HTMLElement>) => {
const container = ref?.current;
if (!container) return;
export const focusFirstElement = (node: HTMLElement) => {
if (!node) return;

const elements = getFocusableElements(container);
const elements = getFocusableElements(node);

if (elements.length > 0) {
elements[0].focus();
Expand Down