outsystems-ui
Version:
OutSystems UI Framework
533 lines (453 loc) • 16.8 kB
text/typescript
// eslint-disable-next-line @typescript-eslint/no-unused-vars
namespace OSFramework.OSUI.Patterns.Submenu {
/**
* Defines the interface for OutSystemsUI Patterns
*/
export class Submenu extends AbstractPattern<SubmenuConfig> implements ISubmenu {
private _eventBalloonKeypress: GlobalCallbacks.Generic;
private _eventClick: GlobalCallbacks.Generic;
private _eventKeypress: GlobalCallbacks.Generic;
private _eventOnMouseEnter: GlobalCallbacks.Generic;
private _eventOnMouseLeave: GlobalCallbacks.Generic;
// Store focus manager instance
private _focusManagerInstance: Behaviors.FocusManager;
// Store focus trap instance
private _focusTrapInstance: Behaviors.FocusTrap;
private _globalEventBody: GlobalCallbacks.Generic;
private _hasActiveLinks = false;
private _hasElements = false;
private _isActive = false;
private _isOpen = false;
private _platformEventOnToggleCallback: GlobalCallbacks.OSGeneric;
private _submenuActiveLinksElement: HTMLElement;
private _submenuAllLinksElement: HTMLAnchorElement[];
private _submenuHeaderElement: HTMLElement;
private _submenuLinksElement: HTMLElement;
public hasClickOutsideToClose = true;
constructor(uniqueId: string, configs: JSON) {
super(uniqueId, new SubmenuConfig(configs));
}
// Close submenu, when BodyOnCLick event is triggered
private _bodyClickCallback(_args: string, e: MouseEvent): void {
if (this.isBuilt && this._isOpen) {
if (!this.selfElement.contains(e.target as HTMLElement)) {
this.close();
}
e.preventDefault();
e.stopPropagation();
}
}
private _checkForActiveLinks(): void {
this._submenuActiveLinksElement =
Helper.Dom.ClassSelector(this._submenuLinksElement, Enum.CssClass.PatternActive) ||
Helper.Dom.ClassSelector(this._submenuHeaderElement, Enum.CssClass.PatternActive);
// Check if submenu contains elements with active class
this._hasActiveLinks = !!this._submenuActiveLinksElement;
}
// Trigger the submenu at toggle behaviour
private _clickCallback(): void {
this._toggleSubmenu();
}
// Method to add Focus Trap to Pattern
private _handleFocusBehavior(): void {
const opts = {
focusTargetElement: this._submenuLinksElement,
} as Behaviors.FocusTrapParams;
this._focusTrapInstance = new Behaviors.FocusTrap(opts);
this._focusManagerInstance = new Behaviors.FocusManager();
// Disable tabIndex to the inner focusable elements if its start closed!
if (this._isOpen === false) {
// Will handle the tabindex value of the elements inside pattern
Helper.A11Y.SetElementsTabIndex(false, this._focusTrapInstance.focusableElements);
}
}
// Call methods to close, based ok e.key
private _keypressBalloonCallback(e: KeyboardEvent): void {
e.stopPropagation();
// Close the submenu when pressing Esc
if (e.key === GlobalEnum.Keycodes.Escape && this._isOpen) {
this.close();
this._submenuHeaderElement.focus();
}
}
// Call methods to open or close, based ok e.key and behavior applied
private _keypressCallback(e: KeyboardEvent): void {
const _clickedElem: HTMLElement = e.target as HTMLElement;
const _closestElem: HTMLElement = _clickedElem.closest(Constants.Dot + Enum.CssClass.Pattern);
const _isEscapedPressed = e.key === GlobalEnum.Keycodes.Escape;
const _isEnterPressed = e.key === GlobalEnum.Keycodes.Enter;
const _isTabPressed = e.key === GlobalEnum.Keycodes.Tab;
const _isShiftPressed = e.shiftKey;
const _isArrowUp = e.key === GlobalEnum.Keycodes.ArrowUp;
const _isArrowDown = e.key === GlobalEnum.Keycodes.ArrowDown;
const _targetAfterArrow = _isArrowUp ? this._focusTrapInstance.focusableElements.length - 1 : 0;
// Open and select first or last item
if (_isArrowDown || _isArrowUp) {
this.open();
Helper.AsyncInvocation(() => {
this._focusTrapInstance.focusableElements[_targetAfterArrow].focus();
});
}
// Open the submenu
if (_isEnterPressed) {
this._toggleSubmenu();
}
// Close the submenu when pressing Esc
if (_isEscapedPressed && this._isOpen) {
this.close();
this._submenuHeaderElement.focus();
}
// If navigate to outside of Submenu, close it
if (_isShiftPressed && _isTabPressed && _clickedElem === this._submenuHeaderElement) {
if (_closestElem === this.selfElement && this._isOpen) {
this.close();
}
}
e.stopPropagation();
}
// Trigger the submenu after an hover behaviour
private _onMouseEnterCallback(e: MouseEvent) {
this.open();
e.stopPropagation();
}
// Close the submenu when the cursor leaves the element
private _onMouseLeaveCallback(e: MouseEvent) {
// Leave event is triggered each time we moved from the scope;
// Here we are validating if we hovering any element under _selfElem
// If we are not hovering it means that we leave the component
if (this.selfElement.querySelector(':hover') === null) {
this.close();
}
e.preventDefault();
e.stopPropagation();
}
// Remove submenu as active
private _removeActive(): void {
if (this._isActive) {
Helper.Dom.Styles.RemoveClass(this.selfElement, Enum.CssClass.PatternActive);
this._isActive = false;
}
}
// Method to remove the event listeners
private _removeEvents(): void {
// Remove events only if has elements inside and OpenOnHover is not available
if (this._hasElements) {
if (this.configs.OpenOnHover === false || Helper.DeviceInfo.IsTouch) {
this._submenuHeaderElement.removeEventListener(GlobalEnum.HTMLEvent.Click, this._eventClick);
this._submenuHeaderElement.removeEventListener(GlobalEnum.HTMLEvent.keyDown, this._eventKeypress);
}
if (this.configs.OpenOnHover && Helper.DeviceInfo.IsTouch === false) {
this.selfElement.removeEventListener(GlobalEnum.HTMLEvent.MouseEnter, this._eventOnMouseEnter);
this.selfElement.removeEventListener(GlobalEnum.HTMLEvent.MouseLeave, this._eventOnMouseLeave);
}
}
// Remove handler from Event Manager
Event.DOMEvents.Listeners.GlobalListenerManager.Instance.removeHandler(
Event.DOMEvents.Listeners.Type.BodyOnClick,
this._globalEventBody
);
}
// Set submenu as active
private _setActive(): void {
if (this._isActive === false) {
Helper.Dom.Styles.AddClass(this.selfElement, Enum.CssClass.PatternActive);
this._isActive = true;
}
}
// Show submenu
private _show(): void {
if (this._isOpen === false) {
Helper.Dom.Styles.AddClass(this.selfElement, Enum.CssClass.PatternIsOpen);
this._submenuHeaderElement.focus();
this._isOpen = true;
this._updateA11yProperties();
}
}
// Trigger the submenu behavior based on visibility
private _toggleSubmenu(): void {
if (this._isOpen) {
this.close();
} else {
// Make async the method call
Helper.AsyncInvocation(this.open.bind(this));
}
}
// Set the cssClasses that should be assigned to the element on it's initialization
private _updateA11yProperties(): void {
Helper.A11Y.AriaExpanded(this._submenuHeaderElement, this._isOpen.toString());
Helper.A11Y.AriaHidden(this._submenuLinksElement, (!this._isOpen).toString());
// Update the tabindex of each link
this._submenuAllLinksElement.forEach((item: HTMLElement) => {
if (this._isOpen) {
Helper.A11Y.TabIndexTrue(item);
Helper.A11Y.AriaHiddenFalse(item);
} else {
Helper.A11Y.TabIndexFalse(item);
Helper.A11Y.AriaHiddenTrue(item);
}
});
}
/**
* Add the A11Y attributes values
*
* @protected
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
protected setA11YProperties(): void {
// Apply the default A11Y
Helper.A11Y.AriaHasPopupTrue(this._submenuHeaderElement);
Helper.A11Y.TabIndexTrue(this._submenuHeaderElement);
Helper.A11Y.AriaControls(this._submenuHeaderElement, this._submenuLinksElement.id);
Helper.A11Y.RoleMenuItem(this._submenuHeaderElement);
// Set the attr that will be used to define the default tabindex element
Helper.Dom.Attribute.Set(
this._submenuHeaderElement,
Constants.FocusableTabIndexDefault,
Constants.EmptyString
);
// Set the role menuitem on each link
this._submenuAllLinksElement.forEach((item: HTMLElement) => {
Helper.A11Y.RoleButton(item);
});
// Method that as the initial A11Y states and to be used on parameters changed
this._updateA11yProperties();
}
/**
* Add Pattern Events
*
* @protected
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
protected setCallbacks(): void {
// Define the callbacks that will be used
this._eventClick = this._clickCallback.bind(this);
this._globalEventBody = this._bodyClickCallback.bind(this);
this._eventBalloonKeypress = this._keypressBalloonCallback.bind(this);
this._eventKeypress = this._keypressCallback.bind(this);
this._eventOnMouseEnter = this._onMouseEnterCallback.bind(this);
this._eventOnMouseLeave = this._onMouseLeaveCallback.bind(this);
// Add events only if has elements inside
if (this._hasElements) {
// OpenOnHover is only available for devices where the hover exists
if (this.configs.OpenOnHover === false || Helper.DeviceInfo.IsTouch) {
this._submenuHeaderElement.addEventListener(GlobalEnum.HTMLEvent.Click, this._eventClick);
}
this._submenuHeaderElement.addEventListener(GlobalEnum.HTMLEvent.keyDown, this._eventKeypress);
}
}
/**
* Update info based on htmlContent
*
* @protected
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
protected setHtmlElements(): void {
this._submenuHeaderElement = Helper.Dom.ClassSelector(this.selfElement, Enum.CssClass.PatternHeader);
this._submenuLinksElement = Helper.Dom.ClassSelector(this.selfElement, Enum.CssClass.PatternLinks);
this._submenuAllLinksElement = [...this._submenuLinksElement.querySelectorAll(GlobalEnum.HTMLElement.Link)];
// Check if submenu has childs
if (this._submenuLinksElement.children.length > 0) {
this._hasElements = true;
}
// Check if submenu contains elements with active class
Helper.AsyncInvocation(this._checkForActiveLinks.bind(this));
}
/**
* Set the cssClasses that should be assigned to the element on it's initialization
*
* @protected
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
protected setInitialStates(): void {
// Add active class to pattern based on links whith active state
if (this._hasActiveLinks) {
this._setActive();
}
// Add an identifier if the pattern has childs
if (this._hasElements) {
Helper.Dom.Styles.AddClass(this.selfElement, Enum.CssClass.PatternIsDropdown);
} else {
Helper.Dom.Styles.AddClass(this._submenuLinksElement, Enum.CssClass.PatternIsHidden);
}
// In case the class modifier was added by extended class
if (Helper.Dom.Styles.ContainsClass(this.selfElement, Enum.CssClass.PatternIsOpen) && !this._isOpen) {
this._isOpen = true;
}
}
/**
* Remove all the assigned Events
*
* @protected
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
protected unsetCallbacks(): void {
// Reassign the elements to undefined, preventing memory leaks
this._eventClick = undefined;
this._eventBalloonKeypress = undefined;
this._eventKeypress = undefined;
this._globalEventBody = undefined;
this._eventOnMouseEnter = undefined;
this._eventOnMouseLeave = undefined;
this._platformEventOnToggleCallback = undefined;
}
/**
* Reassign the HTML elements to undefined, preventing memory leaks
*
* @protected
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
protected unsetHtmlElements(): void {
this._submenuHeaderElement = undefined;
this._submenuLinksElement = undefined;
this._submenuAllLinksElement = undefined;
this._submenuActiveLinksElement = undefined;
}
/**
* Method to build the Submenu
*
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
public build(): void {
super.build();
this.setHtmlElements();
this.setInitialStates();
this._handleFocusBehavior();
this.setA11YProperties();
this.setCallbacks();
this.finishBuild();
}
/**
* Method to toggle the behaviour to close submenu when clicking the body
*
* @param {boolean} clickOutsideToClose
* @memberof Submenu
*/
public clickOutsideToClose(clickOutsideToClose: boolean): void {
this.hasClickOutsideToClose = clickOutsideToClose;
// Make sure the event is updated when the API is triggered in runtime
if (this.hasClickOutsideToClose) {
Event.DOMEvents.Listeners.GlobalListenerManager.Instance.addHandler(
Event.DOMEvents.Listeners.Type.BodyOnClick,
this._globalEventBody
);
} else if (this.hasClickOutsideToClose === false) {
Event.DOMEvents.Listeners.GlobalListenerManager.Instance.removeHandler(
Event.DOMEvents.Listeners.Type.BodyOnClick,
this._globalEventBody
);
}
}
/**
* Close Submenu
*
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
public close(): void {
if (this._isOpen) {
// Remove the A11Y states to focus trap
this._focusTrapInstance.disableForA11y();
if (this.hasClickOutsideToClose) {
// Remove handler from Event Manager
Event.DOMEvents.Listeners.GlobalListenerManager.Instance.removeHandler(
Event.DOMEvents.Listeners.Type.BodyOnClick,
this._globalEventBody
);
}
Helper.Dom.Styles.RemoveClass(this.selfElement, Enum.CssClass.PatternIsOpen);
this._isOpen = false;
this._updateA11yProperties();
this._focusManagerInstance.setFocusToStoredElement();
this._submenuLinksElement.removeEventListener(GlobalEnum.HTMLEvent.keyDown, this._eventBalloonKeypress);
// Trigger the _platformEventOnToggleCallback callback!
this.triggerPlatformEventCallback(this._platformEventOnToggleCallback, false);
}
}
/**
* Destroy the Submenu
*
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
public dispose(): void {
// Remove event listners
this._removeEvents();
this.unsetCallbacks();
// Remove unused HTML elements
this.unsetHtmlElements();
//Destroying the base of pattern
super.dispose();
}
/**
* Public Open Submenu
*
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
public open(): void {
// Add the A11Y states to focus trap
this._focusTrapInstance.enableForA11y();
this._focusManagerInstance.storeLastFocusedElement();
if (this.hasClickOutsideToClose) {
// Add the body click handler to event manager
Event.DOMEvents.Listeners.GlobalListenerManager.Instance.addHandler(
Event.DOMEvents.Listeners.Type.BodyOnClick,
this._globalEventBody
);
}
this._submenuLinksElement.addEventListener(GlobalEnum.HTMLEvent.keyDown, this._eventBalloonKeypress);
// Make async the method call
Helper.AsyncInvocation(this._show.bind(this));
// Trigger the _platformEventOnToggleCallback callback!
this.triggerPlatformEventCallback(this._platformEventOnToggleCallback, true);
}
/**
* Method used to register the provider callback
*
* @param {string} eventName
* @param {GlobalCallbacks.OSGeneric} callback
* @memberof Submenu
*/
public registerCallback(eventName: string, callback: GlobalCallbacks.OSGeneric): void {
switch (eventName) {
case Enum.Events.OnToggle:
if (this._platformEventOnToggleCallback === undefined) {
this._platformEventOnToggleCallback = callback;
}
break;
default:
super.registerCallback(eventName, callback);
}
}
/**
* Manage Callbacks needed to show submenu on hover
*
* @memberof Submenu
*/
public setOpenOnHover(): void {
// OpenOnHover is only available for devices where the hover exists
if (Helper.DeviceInfo.IsTouch === false) {
if (this._hasElements) {
this._submenuHeaderElement.removeEventListener(GlobalEnum.HTMLEvent.Click, this._eventClick);
this._eventClick = undefined;
Helper.Dom.Styles.AddClass(this.selfElement, Enum.CssClass.PatternIsHover);
this.selfElement.addEventListener(GlobalEnum.HTMLEvent.MouseEnter, this._eventOnMouseEnter);
this.selfElement.addEventListener(GlobalEnum.HTMLEvent.MouseLeave, this._eventOnMouseLeave);
}
}
}
/**
* Trigger on submenu onRender, to update active state
*
* @memberof OSFramework.Patterns.Submenu.Submenu
*/
public updateOnRender(): void {
if (this.isBuilt) {
// Check if there are active elements inside
this._checkForActiveLinks();
if (this._hasActiveLinks && this._isActive === false) {
this._setActive();
} else if (this._hasActiveLinks === false && this._isActive) {
this._removeActive();
}
}
}
}
}