import { Arr, Fun, Obj, Optional, Optionals, Singleton } from '@ephox/katamari'; import { Attribute, Class, Classes, SelectorFilter, SelectorFind, SugarBody } from '@ephox/sugar'; import * as EditableFields from '../../alien/EditableFields'; import { Composing } from '../../api/behaviour/Composing'; import { Disabling } from '../../api/behaviour/Disabling'; import { Highlighting } from '../../api/behaviour/Highlighting'; import { Keying } from '../../api/behaviour/Keying'; import { Replacing } from '../../api/behaviour/Replacing'; import { Representing } from '../../api/behaviour/Representing'; import type { AlloyComponent } from '../../api/component/ComponentApi'; import * as GuiFactory from '../../api/component/GuiFactory'; import * as SketchBehaviours from '../../api/component/SketchBehaviours'; import type { AlloySpec } from '../../api/component/SpecTypes'; import * as AlloyEvents from '../../api/events/AlloyEvents'; import * as AlloyTriggers from '../../api/events/AlloyTriggers'; import * as SystemEvents from '../../api/events/SystemEvents'; import * as FocusManagers from '../../api/focus/FocusManagers'; import { Menu } from '../../api/ui/Menu'; import type { SingleSketchFactory } from '../../api/ui/UiSketcher'; import type { NativeSimulatedEvent } from '../../events/SimulatedEvent'; import { LayeredState } from '../../menu/layered/LayeredState'; import * as ItemEvents from '../../menu/util/ItemEvents'; import * as MenuEvents from '../../menu/util/MenuEvents'; import { type OnMenuItemDehighlightedEvent, onMenuItemDehighlightedEvent, type OnMenuItemDehighlightedEventData, type OnMenuItemHighlightedEvent, onMenuItemHighlightedEvent, type OnMenuItemHighlightedEventData } from '../../menu/util/TieredMenuEvents'; import type { MenuFocusEvent, MenuItemHoverEvent } from '../types/MenuTypes'; import { HighlightOnOpen, type PartialMenuSpec, type TieredMenuApis, type TieredMenuDetail, type TieredMenuSpec } from '../types/TieredMenuTypes'; export type MenuPreparation = MenuPrepared | MenuNotBuilt; export interface MenuPrepared { type: 'prepared'; menu: AlloyComponent; } export interface MenuNotBuilt { type: 'notbuilt'; nbMenu: () => AlloySpec; } const make: SingleSketchFactory = (detail, _rawUiSpec) => { const submenuParentItems = Singleton.value>(); // So the way to provide extra configuration for the menus that tiered menus create is just // to provide different menu specs when building up the TieredData. The TieredMenu itself // does not control it, except to set: markers, fakeFocus, onHighlight, and focusManager const buildMenus = (container: AlloyComponent, primaryName: string, menus: Record): Record => Obj.map(menus, (spec, name) => { const makeSketch = () => Menu.sketch({ ...spec, value: name, // The TieredMenu markers should be inherited by the Menu. "Markers" are things like // what is the class for the currently selected item markers: detail.markers, // If the TieredMenu has been configured with FakeFocus, it needs the menus that it generates // to preserve that configuration. Generally, FakeFocus is used for situations where the user // wants to keep focus inside some editable element (like an input, or editor content) fakeFocus: detail.fakeFocus, // The TieredMenu detail.onHighlight function only relates to selecting an item, // not a menu, and the menuComp it is passed is the menu, not the tiered menu. // This makes it a difficult handler to use for a tieredmenu, so we are // deprecating it. onHighlight: (menuComp: AlloyComponent, itemComp: AlloyComponent) => { // Trigger an internal event so that we can listen to it at the tieredmenu // level, and call detail.onHighlightItem handler with tmenu, menu, and item. const highlightData: OnMenuItemHighlightedEventData = { menuComp, itemComp }; AlloyTriggers.emitWith(menuComp, onMenuItemHighlightedEvent, highlightData); }, onDehighlight: (menuComp: AlloyComponent, itemComp: AlloyComponent) => { const dehighlightData: OnMenuItemDehighlightedEventData = { menuComp, itemComp }; // Trigger an internal event so that we can listen to it at the tieredmenu // level, and call detail.onDehighlightItem handler with tmenu, menu, and item. AlloyTriggers.emitWith(menuComp, onMenuItemDehighlightedEvent, dehighlightData); }, // The Menu itself doesn't set the focusManager based on the value of fakeFocus. It only uses // its fakeFocus configuration for creating items that ignore focus, but it still needs to be // told which focusManager to use. Perhaps we should change this, though it does allow for more // complex focusManagers in single menus. focusManager: detail.fakeFocus ? FocusManagers.highlights() : FocusManagers.dom() }); // Only build the primary at first. Build the others as needed. return name === primaryName ? { type: 'prepared', menu: container.getSystem().build(makeSketch()) } as MenuPrepared : { type: 'notbuilt', nbMenu: makeSketch } as MenuNotBuilt; }); const layeredState: LayeredState = LayeredState.init(); const setup = (container: AlloyComponent): Optional => { const componentMap = buildMenus(container, detail.data.primary, detail.data.menus); const directory = toDirectory(container); layeredState.setContents(detail.data.primary, componentMap, detail.data.expansions, directory); return layeredState.getPrimary(); }; const getItemValue = (item: AlloyComponent): string => Representing.getValue(item).value; // Find the first item with value `itemValue` in any of the menus inside this tiered menu structure const getItemByValue = (_container: AlloyComponent, menus: AlloyComponent[], itemValue: string): Optional => // Can *greatly* improve the performance of this by calculating things up front. Arr.findMap(menus, (menu) => { if (!menu.getSystem().isConnected()) { return Optional.none(); } const candidates = Highlighting.getCandidates(menu); return Arr.find(candidates, (c) => getItemValue(c) === itemValue); }); const toDirectory = (_container: AlloyComponent): Record => Obj.map(detail.data.menus, (data, _menuName) => Arr.bind(data.items, (item) => item.type === 'separator' ? [ ] : [ item.data.value ])); // This just sets the active menu. It will not set any active items. const setActiveMenu = Highlighting.highlight; // The item highlighted as active is either the currently active item in the menu, // or the first one. const setActiveMenuAndItem = (container: AlloyComponent, menu: AlloyComponent): void => { // Firstly, choose the active menu setActiveMenu(container, menu); // Then, choose the active item inside the active menu Highlighting.getHighlighted(menu).orThunk(() => Highlighting.getFirst(menu)).each((item) => { if (detail.fakeFocus) { // When using fakeFocus, the items won't have a tab-index, so calling focusItem on them // won't do anything. So we need to manually call highlighting, which is what fakeFocus // uses. It would probably be better to use the focusManager specified. Highlighting.highlight(menu, item); } else { // We don't just use Focusing.focus here, because some items can have slightly different // handling when they respond to a focusItem event. Widgets with autofocus, for example, // will trigger a Keying.focusIn instead of Focusing.focus call, because they want to move // the focus _inside_ the widget, not just to its outer level. The focusItem event // performs a similar purpose to SystemEvents.focus() and potentially, could be consolidated. AlloyTriggers.dispatch(container, item.element, SystemEvents.focusItem()); } }); }; const getMenus = (state: LayeredState, menuValues: string[]): AlloyComponent[] => Optionals.cat( Arr.map(menuValues, (mv) => state.lookupMenu(mv).bind((prep) => prep.type === 'prepared' ? Optional.some(prep.menu) : Optional.none())) ); const closeOthers = (container: AlloyComponent, state: LayeredState, path: string[]): void => { const others = getMenus(state, state.otherMenus(path)); Arr.each(others, (o) => { // May not need to do the active menu thing. Classes.remove(o.element, [ detail.markers.backgroundMenu ]); if (!detail.stayInDom) { Replacing.remove(container, o); } }); }; const getSubmenuParents = (container: AlloyComponent): Record => submenuParentItems.get().getOrThunk(() => { const r: Record = { }; const items = SelectorFilter.descendants(container.element, `.${detail.markers.item}`); const parentItems = Arr.filter(items, (i) => Attribute.get(i, 'aria-haspopup') === 'true'); Arr.each(parentItems, (i) => { container.getSystem().getByDom(i).each((itemComp) => { const key = getItemValue(itemComp); r[key] = itemComp; }); }); submenuParentItems.set(r); return r; }); // Not ideal. Ideally, we would like a map of item keys to components. const updateAriaExpansions = (container: AlloyComponent, path: string[]) => { const parentItems = getSubmenuParents(container); Obj.each(parentItems, (v, k) => { // Really should turn path into a Set const expanded = Arr.contains(path, k); Attribute.set(v.element, 'aria-expanded', expanded); }); }; const updateMenuPath = (container: AlloyComponent, state: LayeredState, path: string[]): Optional => Optional.from(path[0]).bind((latestMenuName) => state.lookupMenu(latestMenuName).bind((menuPrep: MenuPreparation) => { if (menuPrep.type === 'notbuilt') { return Optional.none(); } else { const activeMenu = menuPrep.menu; const rest = getMenus(state, path.slice(1)); Arr.each(rest, (r) => { Class.add(r.element, detail.markers.backgroundMenu); }); if (!SugarBody.inBody(activeMenu.element)) { Replacing.append(container, GuiFactory.premade(activeMenu)); } // Remove the background-menu class from the active menu Classes.remove(activeMenu.element, [ detail.markers.backgroundMenu ]); setActiveMenuAndItem(container, activeMenu); closeOthers(container, state, path); return Optional.some(activeMenu); } })); enum ExpandHighlightDecision { HighlightSubmenu, HighlightParent } const buildIfRequired = (container: AlloyComponent, menuName: string, menuPrep: MenuPreparation) => { if (menuPrep.type === 'notbuilt') { const menu = container.getSystem().build(menuPrep.nbMenu()); layeredState.setMenuBuilt(menuName, menu); return menu; } else { return menuPrep.menu; } }; const expandRight = (container: AlloyComponent, item: AlloyComponent, decision: ExpandHighlightDecision = ExpandHighlightDecision.HighlightSubmenu): Optional => { if (item.hasConfigured(Disabling) && Disabling.isDisabled(item)) { return Optional.some(item); } else { const value = getItemValue(item); return layeredState.expand(value).bind((path) => { // Called when submenus are opened by keyboard AND hovering navigation updateAriaExpansions(container, path); // When expanding, always select the first. return Optional.from(path[0]).bind((menuName) => layeredState.lookupMenu(menuName).bind((activeMenuPrep) => { const activeMenu = buildIfRequired(container, menuName, activeMenuPrep); // DUPE with above. Fix later. if (!SugarBody.inBody(activeMenu.element)) { Replacing.append(container, GuiFactory.premade(activeMenu)); } // updateMenuPath is the code which changes the active menu. We don't always // want to change the active menu. Sometimes, we just want to show it (e.g. hover) detail.onOpenSubmenu(container, item, activeMenu, Arr.reverse(path)); if (decision === ExpandHighlightDecision.HighlightSubmenu) { Highlighting.highlightFirst(activeMenu); return updateMenuPath(container, layeredState, path); } else { Highlighting.dehighlightAll(activeMenu); return Optional.some(item); } })); }); } }; const collapseLeft = (container: AlloyComponent, item: AlloyComponent): Optional => { const value = getItemValue(item); return layeredState.collapse(value).bind((path) => { // Called when submenus are closed because of KEYBOARD navigation updateAriaExpansions(container, path); return updateMenuPath(container, layeredState, path).map((activeMenu) => { detail.onCollapseMenu(container, item, activeMenu); return activeMenu; }); }); }; const updateView = (container: AlloyComponent, item: AlloyComponent): Optional => { const value = getItemValue(item); return layeredState.refresh(value).bind((path) => { // Only this function collapses irrelevant submenus when navigating by HOVERING. // Does mean this is called twice when navigating by hovering, since both // updateView and expandRight are called by the ItemEvents.hover() handler updateAriaExpansions(container, path); return updateMenuPath(container, layeredState, path); }); }; const onRight = (container: AlloyComponent, item: AlloyComponent): Optional => EditableFields.inside(item.element) ? Optional.none() : expandRight(container, item, ExpandHighlightDecision.HighlightSubmenu); const onLeft = (container: AlloyComponent, item: AlloyComponent): Optional => // Exclude inputs, textareas etc. EditableFields.inside(item.element) ? Optional.none() : collapseLeft(container, item); const onEscape = (container: AlloyComponent, item: AlloyComponent): Optional => collapseLeft(container, item).orThunk(() => detail.onEscape(container, item).map(() => container) // This should only fire when the user presses ESC ... not any other close. ); type KeyHandler = (container: AlloyComponent, simulatedEvent: NativeSimulatedEvent) => Optional; const keyOnItem = (f: (container: AlloyComponent, item: AlloyComponent) => Optional): KeyHandler => (container: AlloyComponent, simulatedEvent: NativeSimulatedEvent): Optional => { // 2022-08-16 This seems to be the only code in alloy that actually uses // the getSource aspect of an event. Remember, that this code is firing // when an event bubbles up the tiered menu, e.g. left arrow key. // The only current code that sets the source manually is in the Widget item // type, and it only sets the source when it is using autofocus. Autofocus // is used to essentially treat the widget like it is the top-level item, so // when events originate from *within* the widget, their source is changed to // the top-level item. Consider removing EventSource from alloy altogether. return SelectorFind.closest(simulatedEvent.getSource(), `.${detail.markers.item}`) .bind((target) => container.getSystem().getByDom(target).toOptional().bind( (item: AlloyComponent) => f(container, item).map(Fun.always) )); }; // NOTE: Many of these events rely on identifying the current item by information // sent with the event. However, in situations where you are using fakeFocus, but // the real focus is still somewhere in the menu (e.g. search bar), this will lead to // an incorrect identification of the active item. Ideally, instead of pulling the // item from the event, we should just use Highlighting to identify the active item, // and operate on it. However, not all events will necessarily have to happen on the // active item, so we need to consider all the cases before making this change. For now, // there will be a known limitation that if the real focus is still inside the TieredMenu, // but the menu is using fakeFocus, then the actions will operate on the wrong targets. // A workaround for that is to stop or cut or redispatch the events in whichever // component has the real focus. // TODO: TINY-9011 Introduce proper handling of fakeFocus in TieredMenu const events = AlloyEvents.derive([ // Set "active-menu" for the menu with focus AlloyEvents.run(MenuEvents.focus(), (tmenu, simulatedEvent) => { // Ensure the item is actually part of this menu structure, and not part of another menu structure that's bubbling. const item = simulatedEvent.event.item; layeredState.lookupItem(getItemValue(item)).each(() => { const menu = simulatedEvent.event.menu; Highlighting.highlight(tmenu, menu); const value = getItemValue(simulatedEvent.event.item); layeredState.refresh(value).each((path) => closeOthers(tmenu, layeredState, path)); }); }), AlloyEvents.runOnExecute((component, simulatedEvent) => { // Trigger on execute on the targeted element // I.e. clicking on menu item const target = simulatedEvent.event.target; component.getSystem().getByDom(target).each((item) => { const itemValue = getItemValue(item); // INVESTIGATE: I don't know if this is doing anything any more. Check. if (itemValue.indexOf('collapse-item') === 0) { collapseLeft(component, item); } expandRight(component, item, ExpandHighlightDecision.HighlightSubmenu).fold( () => { detail.onExecute(component, item); }, Fun.noop ); }); }), // Open the menu as soon as it is added to the DOM AlloyEvents.runOnAttached((container, _simulatedEvent) => { setup(container).each((primary) => { Replacing.append(container, GuiFactory.premade(primary)); detail.onOpenMenu(container, primary); if (detail.highlightOnOpen === HighlightOnOpen.HighlightMenuAndItem) { setActiveMenuAndItem(container, primary); } else if (detail.highlightOnOpen === HighlightOnOpen.HighlightJustMenu) { setActiveMenu(container, primary); } }); }), // Listen to the events bubbling up from menu about highlighting, and trigger // our handlers with tmenu, menu and item AlloyEvents.run(onMenuItemHighlightedEvent, (tmenuComp, se) => { detail.onHighlightItem(tmenuComp, se.event.menuComp, se.event.itemComp); }), AlloyEvents.run(onMenuItemDehighlightedEvent, (tmenuComp, se) => { detail.onDehighlightItem(tmenuComp, se.event.menuComp, se.event.itemComp); }), ...( detail.navigateOnHover ? [ // Hide any irrelevant submenus and expand any submenus based // on hovered item AlloyEvents.run(ItemEvents.hover(), (tmenu, simulatedEvent) => { const item = simulatedEvent.event.item; updateView(tmenu, item); expandRight(tmenu, item, ExpandHighlightDecision.HighlightParent); detail.onHover(tmenu, item); }) ] : [ ] ) ]); const getActiveItem = (container: AlloyComponent): Optional => Highlighting.getHighlighted(container).bind(Highlighting.getHighlighted); const collapseMenuApi = (container: AlloyComponent) => { getActiveItem(container).each((currentItem) => { collapseLeft(container, currentItem); }); }; const highlightPrimary = (container: AlloyComponent) => { layeredState.getPrimary().each((primary) => { setActiveMenuAndItem(container, primary); }); }; const extractMenuFromContainer = (container: AlloyComponent) => Optional.from(container.components()[0]).filter((comp) => Attribute.get(comp.element, 'role') === 'menu'); const repositionMenus = (container: AlloyComponent): void => { // Get the primary menu const maybeActivePrimary = layeredState.getPrimary().bind((primary) => // Get the triggering path (item, menu) up to the active item getActiveItem(container).bind((currentItem) => { const itemValue = getItemValue(currentItem); const allMenus: MenuPreparation[] = Obj.values(layeredState.getMenus()); const preparedMenus: AlloyComponent[] = Optionals.cat( Arr.map(allMenus, LayeredState.extractPreparedMenu) ); return layeredState.getTriggeringPath(itemValue, (v) => getItemByValue(container, preparedMenus, v)); }).map((triggeringPath) => ({ primary, triggeringPath })) ); maybeActivePrimary.fold(() => { // When a menu is open but there is no activeItem, we get the menu from the container. extractMenuFromContainer(container).each((primaryMenu) => { detail.onRepositionMenu(container, primaryMenu, []); }); }, ({ primary, triggeringPath }) => { // Refresh all the menus up to the active item detail.onRepositionMenu(container, primary, triggeringPath); }); }; const apis: TieredMenuApis = { collapseMenu: collapseMenuApi, highlightPrimary, repositionMenus }; return { uid: detail.uid, dom: detail.dom, markers: detail.markers, behaviours: SketchBehaviours.augment( detail.tmenuBehaviours, [ Keying.config({ mode: 'special', onRight: keyOnItem(onRight), onLeft: keyOnItem(onLeft), onEscape: keyOnItem(onEscape), focusIn: (container, _keyInfo) => { layeredState.getPrimary().each((primary) => { AlloyTriggers.dispatch(container, primary.element, SystemEvents.focusItem()); }); } }), // Highlighting is used for highlighting the active menu Highlighting.config({ highlightClass: detail.markers.selectedMenu, itemClass: detail.markers.menu }), Composing.config({ find: (container) => { return Highlighting.getHighlighted(container); } }), Replacing.config({ }) ] ), eventOrder: detail.eventOrder, apis, events }; }; const collapseItem = Fun.constant('collapse-item'); export { make, collapseItem };