import { Component, Prop, State, Watch, h, Listen, Element, Event, EventEmitter } from '@stencil/core';
import { HeaderMenuToggleDetail } from '../ontario-header/ontario-header.interface';
import { MenuItem } from '../../utils/common/common.interface';
import { Language } from '../../utils/common/language-types';
import { validateLanguage } from '../../utils/validation/validation-functions';
import { convertStringToBoolean } from '../../utils/helper/utils';
import { HeaderKeyboardNavigation } from '../../utils/components/header/header-keyboard-navigation';
/**
* Ontario Header Overflow Menu displays overflow navigation links for header contexts.
*
* It can operate in two modes:
*
* ### Standalone Mode
*
* Used when placed directly in the header (desktop view).
* - Manages its own open/close state via `menuButtonToggled` event
* - Automatically focuses first menu item when opened
* - Sets up focus trap to keep keyboard navigation within menu
* - Auto-closes when focus leaves the menu area
* - **Emits**: `menuClosed` event when menu closes (for cleanup/state sync)
*
* ### Embedded Mode
*
* Used when placed inside `ontario-header-menu-tabs` (mobile/tablet view).
* - Parent component controls open/close state
* - Parent component manages focus trap
* - Menu is always visible when parent tab is active
* - **Emits**: `endOfMenuReached` event when Tab is pressed on last item (for focus looping)
*
* ### Mode Detection
*
* - Auto-detected based on DOM position (no prop needed).
* - Checks if ancestor is `ontario-header-menu-tabs` or `.ontario-mobile-menu__panel`.
*
* For component guidance, see:
* - https://designsystem.ontario.ca/components/detail/ontario-header.html
* - https://designsystem.ontario.ca/components/detail/application-header.html
* - https://designsystem.ontario.ca/components/detail/service-ontario-header.html
* - https://designsystem.ontario.ca/developer-docs/components/ontario-header-overflow-menu/
*/
@Component({
tag: 'ontario-header-overflow-menu',
styleUrl: 'ontario-header-overflow-menu.scss',
shadow: true,
})
export class OntarioHeaderOverflowMenu {
@Element() el: HTMLElement;
/**
* The menu items to display.
* Can be passed as a MenuItem array or JSON string.
*
* The items that will go inside the menu.
*
* @example
*
*
*/
@Prop() menuItems: MenuItem[] | string;
/**
* Whether this is the last menu in a series of menus.
* If true, Tab from last item goes to next element on page.
* If false, Tab from last item emits focusNextElement for header to handle.
*/
@Prop() isLastMenu?: boolean = true;
/**
* Whether Tab from the last menu item should return focus to the trigger button
* instead of moving to the next menu or next page element.
*/
@Prop() returnFocusToTriggerOnLastTab?: boolean = false;
/**
* Whether the standalone menu should move focus to the first item when opened.
* This should only be true for keyboard-triggered opens.
*/
@Prop() focusFirstItemOnOpen?: boolean = false;
/**
* The language of the component.
* This is used for translations, and is by default set through event listeners checking for a language property from the header. If none is passed, it will default to English.
*/
@Prop({ mutable: true }) language?: Language = 'en';
/**
* Parsed menu items (converted from string if needed).
*/
@State() private menuItemState: MenuItem[];
/**
* Whether the menu dropdown is currently open (standalone mode only).
*/
@State() private menuIsOpen: boolean = false;
/**
* Current focused menu item index for arrow key navigation.
*/
private currentIndex: number | undefined = undefined;
/**
* Flag to track if auto-close check should happen.
*/
private shouldCheckAutoClose: boolean = true;
/**
* Flag to prevent focusin from interfering with arrow navigation.
*/
private isArrowNavigating = false;
/**
* Reference to the menu container element.
*/
private menu!: HTMLElement;
/**
* Reference to the ARIA live region for screen reader announcements.
*/
private ariaLiveRegion!: HTMLElement;
/**
* Prevent the same Arrow key event that opened/focused the embedded menu
* from immediately advancing to the next item.
*/
private suppressNextArrowNavigation = false;
/**
* Runtime mode detection.
* Returns true when standalone (not inside tabs or mobile panel).
*/
private get isStandalone(): boolean {
return !this.el.closest('ontario-header-menu-tabs') && !this.el.closest('.ontario-mobile-menu__panel');
}
/**
* Whether we've already focused the first item after menu opened.
*/
private hasInitializedFocus = false;
/**
* Whether the menu should move focus to the first item when it opens.
*/
private shouldFocusFirstItemOnOpen = false;
/**
* Lifecycle hook called before the component is loaded.
*/
componentWillLoad() {
this.parseMenuItems();
}
/**
* Lifecycle hook called after component updates.
* Focuses first menu item when menu opens in standalone mode.
*/
componentDidUpdate() {
if (this.menuIsOpen && this.isStandalone && this.shouldFocusFirstItemOnOpen && !this.hasInitializedFocus) {
this.focusFirstMenuItem();
this.hasInitializedFocus = true;
}
if (!this.menuIsOpen) {
this.hasInitializedFocus = false;
}
}
/**
* Watch for changes to menuItems prop and re-parse them.
*/
@Watch('menuItems')
parseMenuItems() {
this.menuItemState = this.parseMenuItemsData(this.menuItems) || [];
this.setActiveLink(this.menuItemState);
}
/**
* Event emitted when menu closes (standalone mode).
*/
@Event({ eventName: 'menuClosed', bubbles: true, composed: true })
menuClosed!: EventEmitter;
/**
* Event emitted when Tab is pressed on the last menu item (embedded mode).
*/
@Event({ eventName: 'endOfMenuReached', bubbles: true, composed: true })
endOfMenuReached!: EventEmitter;
/**
* Event emitted when Shift+Tab is pressed on first menu item.
* Tells the header to focus the menu button.
*/
@Event({ eventName: 'focusMenuButton', bubbles: true, composed: true })
focusMenuButtonEvent!: EventEmitter;
/**
* Event emitted when Tab is pressed on the last menu item in standalone mode.
* Tells the header to focus the next appropriate element.
*/
@Event({ eventName: 'focusNextElement', bubbles: true, composed: true })
focusNextElement!: EventEmitter;
/**
* Event emitted when user Tabs from the menu button.
* Asks if menu is open and ready to receive focus.
*/
@Event({ eventName: 'menuButtonTabPressed', bubbles: true, composed: true })
menuButtonTabPressed!: EventEmitter;
/**
* Listen for focus first item event from menu tabs (embedded mode).
*/
@Listen('focusFirstItem', { target: 'window' })
handleFocusFirstItem() {
if (!this.isStandalone) {
this.suppressNextArrowNavigation = true;
this.focusFirstMenuItem();
}
}
/**
* Listen for Tab pressed from menu button.
* If menu is open and in standalone mode, focus first item.
*/
@Listen('menuButtonTabPressed', { target: 'window' })
handleMenuButtonTab() {
if (this.isStandalone && this.menuIsOpen) {
this.focusFirstMenuItem();
}
}
/**
* Listen for menu button toggle events (standalone mode only).
*/
@Listen('menuButtonToggled', { target: 'window' })
handleMenuButtonToggled(event: CustomEvent) {
if (!this.isStandalone) return;
const detail =
typeof event.detail === 'boolean' ? { isOpen: event.detail, trigger: 'programmatic' as const } : event.detail;
this.menuIsOpen = detail.isOpen;
this.shouldFocusFirstItemOnOpen = detail.isOpen && detail.trigger === 'keyboard';
if (!this.menuIsOpen) {
this.resetState();
}
}
/**
* Handle keyboard navigation within the menu.
*/
@Listen('keydown', { target: 'window' })
handleKeyDown(event: KeyboardEvent) {
if (this.isStandalone) {
this.handleStandaloneKeyboard(event);
} else {
this.handleEmbeddedKeyboard(event);
}
}
/**
* Listen for signal that focus has returned to menu button.
*/
@Listen('menuButtonFocused', { target: 'window' })
handleMenuButtonFocused() {
// Focus is back on button, don't close the menu
this.shouldCheckAutoClose = false;
}
/**
* Auto-close menu when focus leaves (standalone mode only).
*/
@Listen('focusout', { target: 'window' })
handleFocusOut(event: FocusEvent) {
if (!this.isStandalone || !this.menuIsOpen) return;
this.shouldCheckAutoClose = true;
setTimeout(() => {
const focusedElement = event.relatedTarget as HTMLElement;
const focusInMenu = this.menu?.contains(focusedElement) || this.el.shadowRoot?.contains(focusedElement);
if (!focusInMenu && this.shouldCheckAutoClose) {
this.menuIsOpen = false;
this.resetState();
this.menuClosed.emit();
}
}, 0);
}
/**
* Sync currentIndex when focus enters a menu item (after Tab navigation).
*/
@Listen('focusin', { target: 'window' })
handleFocusIn(event: FocusEvent) {
if (!this.menuIsOpen) return;
// Don't interfere with arrow navigation
if (this.isArrowNavigating) {
return;
}
const target = event.target as HTMLElement;
// Check if the target is within our menu
const isInMenu = this.menu?.contains(target) || this.el.shadowRoot?.contains(target);
if (!isInMenu) {
return;
}
const focusable = this.getFocusableElements();
const focusedIndex = focusable.findIndex((el) => el === target || el.contains(target));
if (focusedIndex !== -1) {
this.currentIndex = focusedIndex;
}
}
/**
* This listens for the `setAppLanguage` event sent from the language toggle when it is connected to the DOM.
* It is used for the initial language when the component loads.
*/
@Listen('setAppLanguage', { target: 'window' })
handleSetAppLanguage(event: CustomEvent | Language) {
this.language = validateLanguage(event);
}
/**
* This listens for the `headerLanguageToggled` event sent from the language toggle when it is connected to the DOM.
* It is used for changing the component language after the language toggle has been activated.
*/
@Listen('headerLanguageToggled', { target: 'window' })
handleLanguageToggle(event: CustomEvent<{ oldLanguage: Language; newLanguage: Language }>) {
this.handleSetAppLanguage(event.detail.newLanguage);
}
/**
* Sync currentIndex after Tab navigation completes.
*/
private syncIndexAfterTab(focusable: HTMLElement[]) {
requestAnimationFrame(() => {
const newActive = this.el.shadowRoot?.activeElement || document.activeElement;
const newIndex = focusable.findIndex((el) => el === newActive || el.contains(newActive as Node));
if (newIndex !== -1) {
this.currentIndex = newIndex;
}
});
}
/**
* Handle keyboard events in standalone mode.
*/
private handleStandaloneKeyboard(event: KeyboardEvent) {
if (!this.menuIsOpen) return;
const shadowActive = this.el.shadowRoot?.activeElement as HTMLElement;
const focusInThisMenu = shadowActive && this.el.shadowRoot?.contains(shadowActive);
if (!focusInThisMenu) return;
const focusable = this.getFocusableElements();
// Handle Shift+Tab from first item -> close menu and focus menu button
if (event.key === 'Tab' && event.shiftKey) {
if (focusable.length && shadowActive === focusable[0]) {
event.preventDefault();
this.focusMenuButtonEvent.emit();
// Use requestAnimationFrame to close menu after focus event is processed
requestAnimationFrame(() => {
this.menuIsOpen = false;
this.resetState();
this.menuClosed.emit();
});
return;
}
this.syncIndexAfterTab(focusable);
}
// Handle Tab from last item
if (event.key === 'Tab' && !event.shiftKey) {
if (focusable.length && shadowActive === focusable[focusable.length - 1]) {
if (this.returnFocusToTriggerOnLastTab) {
event.preventDefault();
this.focusMenuButtonEvent.emit();
requestAnimationFrame(() => {
this.menuIsOpen = false;
this.resetState();
this.menuClosed.emit();
});
return;
}
if (this.isLastMenu) {
// Last menu: let browser focus next tabbable element
this.menuIsOpen = false;
this.resetState();
this.menuClosed.emit();
} else {
// Not last menu: emit event to focus next menu button
event.preventDefault();
this.menuIsOpen = false;
this.resetState();
this.menuClosed.emit();
this.focusNextElement.emit();
}
return;
}
this.syncIndexAfterTab(focusable);
}
this.handleArrowNavigation(event);
}
/**
* Handle keyboard events in embedded mode.
*/
private handleEmbeddedKeyboard(event: KeyboardEvent) {
const shadowActive = this.el.shadowRoot?.activeElement as HTMLElement;
const domActive = document.activeElement as HTMLElement;
const focusInThisMenu = !!(
(shadowActive && this.el.shadowRoot?.contains(shadowActive)) ||
(domActive && this.menu?.contains(domActive))
);
if (!focusInThisMenu) return;
const focusable = this.getFocusableElements();
// Emit event when Tab from last item
if (event.key === 'Tab' && !event.shiftKey) {
const currentElement = shadowActive || domActive;
const currentIndex = focusable.findIndex((el) => el === currentElement || el.contains(currentElement as Node));
if (currentIndex === focusable.length - 1) {
event.preventDefault();
this.endOfMenuReached.emit();
return;
}
this.syncIndexAfterTab(focusable);
}
// Handle Shift+Tab - sync after browser moves focus
if (event.key === 'Tab' && event.shiftKey) {
this.syncIndexAfterTab(focusable);
}
this.handleArrowNavigation(event);
}
/**
* Handle arrow key navigation (both modes).
* Navigates through menu items with wrapping - arrows loop from last to first item and vice versa.
*/
private handleArrowNavigation(event: KeyboardEvent) {
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
if (this.suppressNextArrowNavigation) {
this.suppressNextArrowNavigation = false;
return;
}
const focusable = this.getFocusableElements();
if (!focusable.length) return;
event.preventDefault();
this.isArrowNavigating = true;
// If we don't have an index, sync with current focus first
if (this.currentIndex === undefined) {
const activeElement = this.el.shadowRoot?.activeElement || document.activeElement;
const focusedIndex = focusable.findIndex((el) => el === activeElement);
this.currentIndex = focusedIndex !== -1 ? focusedIndex : 0;
}
// Now move in the requested direction
if (event.key === 'ArrowDown') {
this.currentIndex = (this.currentIndex + 1) % focusable.length;
} else {
this.currentIndex = (this.currentIndex - 1 + focusable.length) % focusable.length;
}
focusable[this.currentIndex].focus();
this.updateAriaLive(this.currentIndex);
// Reset flag after focus has been set
this.isArrowNavigating = false;
}
/**
* Focus the first menu item.
* Sets currentIndex to 0 to track position for arrow navigation.
*/
private focusFirstMenuItem() {
const focusable = this.getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
this.currentIndex = 0;
this.updateAriaLive(0);
}
}
/**
* Reset state when menu closes.
*/
private resetState() {
this.currentIndex = undefined;
this.shouldCheckAutoClose = true;
this.suppressNextArrowNavigation = false;
this.shouldFocusFirstItemOnOpen = false;
this.hasInitializedFocus = false;
}
/**
* Get all focusable elements within the menu.
*/
private getFocusableElements(): HTMLElement[] {
return HeaderKeyboardNavigation.getFocusableElements(this.menu);
}
/**
* Update ARIA live region to announce current menu item position.
*/
private updateAriaLive(selectedIndex: number) {
HeaderKeyboardNavigation.updateAriaLive(this.ariaLiveRegion, selectedIndex, this.menuItemState);
}
/**
* Parse menu items from prop (array or JSON string).
*/
private parseMenuItemsData(items: MenuItem[] | string | undefined): MenuItem[] | null {
if (!items) return null;
if (typeof items === 'string') {
const parsed = JSON.parse(items) as MenuItem[];
parsed.forEach((item) => {
if (typeof item?.linkIsActive === 'string') {
item.linkIsActive = convertStringToBoolean(item.linkIsActive);
}
});
return parsed;
}
return Array.isArray(items) ? items : null;
}
/**
* Get text in current language from a string or bilingual object.
*/
private getText(text: string | { en: string; fr: string } | undefined): string {
if (!text) return '';
if (typeof text === 'string') return text;
return text[this.language ?? 'en'] || text.en || '';
}
/**
* Set active link based on current URL if none specified.
*/
private setActiveLink(menuItems: MenuItem[]) {
if (!menuItems) return;
// SSR guard — skip auto-activation when window is not available
if (typeof window === 'undefined') return;
const hasActiveLink = menuItems.some((item) => item?.linkIsActive === true);
if (hasActiveLink) return;
menuItems.forEach((item) => {
const sanitizedSlug = item.href.replace(/\s+/g, '-').toLowerCase();
item.linkIsActive = window.location.pathname.includes(sanitizedSlug);
});
}
render() {
const navClass = this.isStandalone
? this.menuIsOpen
? 'ontario-application-navigation ontario-navigation--open'
: 'ontario-application-navigation'
: 'ontario-application-navigation ontario-navigation--open ontario-application-navigation--embedded';
const ariaHidden = this.isStandalone ? String(!this.menuIsOpen) : 'false';
return (
);
}
}