org.patternfly.component.navigation.Navigation Maven / Gradle / Ivy
/*
* Copyright 2023 Red Hat
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.patternfly.component.navigation;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import org.gwtproject.event.shared.HandlerRegistration;
import org.jboss.elemento.Attachable;
import org.jboss.elemento.ButtonType;
import org.jboss.elemento.By;
import org.jboss.elemento.Elements;
import org.jboss.elemento.EventType;
import org.jboss.elemento.HTMLContainerBuilder;
import org.jboss.elemento.logger.Logger;
import org.patternfly.component.BaseComponent;
import org.patternfly.component.ComponentType;
import org.patternfly.component.HasItems;
import org.patternfly.component.divider.Divider;
import org.patternfly.component.navigation.NavigationType.Horizontal;
import org.patternfly.core.Aria;
import org.patternfly.core.Dataset;
import org.patternfly.core.LanguageDirection;
import org.patternfly.core.ObservableValue;
import org.patternfly.core.Roles;
import org.patternfly.handler.SelectHandler;
import org.patternfly.handler.ToggleHandler;
import org.patternfly.style.Brightness;
import org.patternfly.style.Classes;
import elemental2.dom.Element;
import elemental2.dom.Event;
import elemental2.dom.HTMLButtonElement;
import elemental2.dom.HTMLElement;
import elemental2.dom.MutationRecord;
import elemental2.dom.Node;
import elemental2.dom.NodeList;
import static elemental2.dom.DomGlobal.clearTimeout;
import static elemental2.dom.DomGlobal.setTimeout;
import static elemental2.dom.DomGlobal.window;
import static org.jboss.elemento.Elements.button;
import static org.jboss.elemento.Elements.div;
import static org.jboss.elemento.Elements.insertAfter;
import static org.jboss.elemento.Elements.insertBefore;
import static org.jboss.elemento.Elements.isAttached;
import static org.jboss.elemento.Elements.isElementInView;
import static org.jboss.elemento.Elements.nav;
import static org.jboss.elemento.Elements.removeChildrenFrom;
import static org.jboss.elemento.Elements.setVisible;
import static org.jboss.elemento.Elements.ul;
import static org.jboss.elemento.EventType.bind;
import static org.jboss.elemento.EventType.click;
import static org.jboss.elemento.EventType.resize;
import static org.patternfly.component.divider.Divider.divider;
import static org.patternfly.component.divider.DividerType.li;
import static org.patternfly.component.navigation.NavigationType.Horizontal.primary;
import static org.patternfly.component.navigation.NavigationType.Horizontal.secondary;
import static org.patternfly.component.navigation.NavigationType.Horizontal.tertiary;
import static org.patternfly.component.navigation.NavigationType.Vertical.expandable;
import static org.patternfly.component.navigation.NavigationType.Vertical.flat;
import static org.patternfly.component.navigation.NavigationType.Vertical.grouped;
import static org.patternfly.core.Aria.hidden;
import static org.patternfly.core.Aria.label;
import static org.patternfly.core.Attributes.role;
import static org.patternfly.core.LanguageDirection.languageDirection;
import static org.patternfly.core.ObservableValue.ov;
import static org.patternfly.core.Validation.verifyEnum;
import static org.patternfly.icon.IconSets.fas.angleLeft;
import static org.patternfly.icon.IconSets.fas.angleRight;
import static org.patternfly.style.Brightness.dark;
import static org.patternfly.style.Brightness.light;
import static org.patternfly.style.Classes.button;
import static org.patternfly.style.Classes.component;
import static org.patternfly.style.Classes.current;
import static org.patternfly.style.Classes.horizontal;
import static org.patternfly.style.Classes.horizontalSubnav;
import static org.patternfly.style.Classes.link;
import static org.patternfly.style.Classes.list;
import static org.patternfly.style.Classes.modifier;
import static org.patternfly.style.Classes.nav;
import static org.patternfly.style.Classes.scroll;
import static org.patternfly.style.Classes.scrollable;
import static org.patternfly.style.TypedModifier.swap;
/**
* A navigation organizes an application's structure and content, making it easy to find information and accomplish tasks.
* Navigation communicates relationships, context, and actions a user can take within an application.
*
* {@snippet class = NavigationDemo region = horizontal}
*
* {@snippet class = NavigationDemo region = grouped}
*
* {@snippet class = NavigationDemo region = expandable}
*
* @see https://www.patternfly.org/components/navigation
*/
public class Navigation extends BaseComponent implements
HasItems,
Attachable {
// ------------------------------------------------------ factory
public static Navigation navigation(NavigationType type) {
return new Navigation(type);
}
// ------------------------------------------------------ instance
private static final Logger logger = Logger.getLogger(Navigation.class.getName());
private static final By A_NAV_LINK_CURRENT = By.element("a")
.and(By.classnames(component(nav, link), modifier(current)));
private static final By LI_NAV_ITEM_EXPANDABLE = By.element("li")
.and(By.classnames(component(nav, Classes.item), modifier(Classes.expandable)));
private final NavigationType type;
private final HTMLElement itemsContainer;
private final Map items;
private final Map groups;
private final Map expandableGroups;
private final List> selectHandler;
private final List> toggleHandler;
private final ObservableValue enableScrollButtons;
private final ObservableValue showScrollButtons;
private final ObservableValue renderScrollButtons;
private final ObservableValue disableBackScrollButton;
private final ObservableValue disableForwardScrollButton;
private double scrollTimeout;
private HandlerRegistration resizeHandler;
private HandlerRegistration transitionEndHandler;
private HTMLContainerBuilder scrollBack;
private HTMLContainerBuilder scrollForward;
Navigation(NavigationType type) {
super(ComponentType.Navigation, nav().css(component(nav)).element());
this.type = type;
this.items = new LinkedHashMap<>();
this.groups = new LinkedHashMap<>();
this.expandableGroups = new LinkedHashMap<>();
this.selectHandler = new ArrayList<>();
this.toggleHandler = new ArrayList<>();
this.enableScrollButtons = ov(false);
this.showScrollButtons = ov(false);
this.renderScrollButtons = ov(false);
this.disableBackScrollButton = ov(false);
this.disableForwardScrollButton = ov(false);
if (type == secondary || type == tertiary) {
aria(label, "Local");
} else {
aria(label, "Global");
}
if (type instanceof Horizontal) {
if (type == primary) {
css(modifier(horizontal));
} else if (type == secondary) {
css(modifier(horizontalSubnav));
} else if (type == tertiary) {
css(modifier(Classes.tertiary));
}
add(scrollBack = button(ButtonType.button).css(component(nav, scroll, button))
.apply(b -> b.disabled = true)
.aria(hidden, true)
.aria(label, "Scroll back")
.on(click, e -> scrollBack())
.add(angleLeft()));
add(itemsContainer = ul().css(component(nav, list))
.attr(role, Roles.list)
.on(EventType.scroll, e -> updateScrollState())
.element());
add(scrollForward = button(ButtonType.button).css(component(nav, scroll, button))
.apply(b -> b.disabled = true)
.aria(hidden, true)
.aria(label, "Scroll forward")
.on(click, e -> scrollForward())
.add(angleRight()));
} else if (type instanceof NavigationType.Vertical) {
NavigationType.Vertical vt = (NavigationType.Vertical) type;
switch (vt) {
case flat:
case expandable:
add(itemsContainer = ul().css(component(nav, list))
.attr(role, Roles.list)
.element());
break;
case grouped:
itemsContainer = element();
break;
case drillDown:
case flyout:
logger.error("Drill-down and fly-out not yet implemented");
itemsContainer = div().element();
break;
default:
logger.error("Unknown navigation type: '%s' for navigation %o", type, element());
itemsContainer = div().element();
}
} else {
logger.error("Unknown navigation type: '%s' for navigation %o", type, element());
itemsContainer = div().element();
}
Attachable.register(this, this);
}
@Override
public void attach(MutationRecord mutationRecord) {
if (type instanceof Horizontal && scrollBack != null && scrollForward != null) {
enableScrollButtons.subscribe((current, previous) -> {
if (!previous && current) {
renderScrollButtons.change(true);
setTimeout(__ -> {
transitionEndHandler = bind(scrollBack, "transitionend", e -> hideScrollButtons());
showScrollButtons.set(true);
}, 100);
} else if (previous && !current) {
showScrollButtons.change(false);
}
});
showScrollButtons.subscribe((current, __) -> classList().toggle(modifier(scrollable), current));
renderScrollButtons.subscribe((current, __) -> {
setVisible(scrollBack, current);
setVisible(scrollForward, current);
});
disableBackScrollButton.subscribe((current, __) -> {
scrollBack.element().disabled = current;
scrollBack.aria(Aria.disabled, current);
});
disableForwardScrollButton.subscribe((current, __) -> {
scrollForward.element().disabled = current;
scrollForward.aria(Aria.disabled, current);
});
resizeHandler = bind(window, resize.name, e -> updateScrollState());
updateScrollState();
}
}
@Override
public void detach(MutationRecord mutationRecord) {
clearTimeout(scrollTimeout);
if (resizeHandler != null) {
resizeHandler.removeHandler();
}
if (transitionEndHandler != null) {
transitionEndHandler.removeHandler();
}
}
// ------------------------------------------------------ add
public Navigation addItems(Iterable items, Function display) {
if (type == grouped) {
logger.warn("addItem(NavigationItem) is not supported for type '%s' in navigation %o", type, element());
return this;
}
for (T item : items) {
NavigationItem navigationItem = display.apply(item);
addItem(navigationItem);
}
return this;
}
public Navigation addItem(NavigationItem item) {
return add(item);
}
public Navigation add(NavigationItem item) {
if (type == grouped) {
logger.warn("addItem(NavigationItem) is not supported for type '%s' in navigation %o", type, element());
return this;
}
internalAddItem(item, itm -> itemsContainer.appendChild(itm.element()));
return this;
}
public Navigation addGroup(NavigationGroup group) {
return add(group);
}
public Navigation add(NavigationGroup group) {
if (type == flat || type == expandable || type instanceof Horizontal) {
logger.warn("addGroup(NavigationGroup) is not supported for type '%s' in navigation %o", type, element());
return this;
}
internalAddGroup(group, grp -> itemsContainer.appendChild(grp.element()));
return this;
}
public Navigation addGroup(ExpandableNavigationGroup group) {
return add(group);
}
public Navigation add(ExpandableNavigationGroup group) {
if (type == flat || type == grouped || type instanceof Horizontal) {
logger.warn("addGroup(ExpandableNavigationGroup) is not supported for type '%s' in navigation %o", type, element());
return this;
}
internalAddGroup(group, grp -> itemsContainer.appendChild(group.element()));
return this;
}
public Navigation addDivider() {
return add(divider(li));
}
public Navigation add(Divider divider) {
itemsContainer.appendChild(divider.element());
return this;
}
public Navigation insertItemBefore(NavigationItem item, String beforeIdentifier) {
HTMLElement element = Elements.find(itemsContainer, By.data(Dataset.identifier, beforeIdentifier));
if (element != null) {
internalAddItem(item, itm -> insertBefore(itm.element(), element));
}
return this;
}
public Navigation insertItemAfter(NavigationItem item, String afterIdentifier) {
HTMLElement element = Elements.find(itemsContainer, By.data(Dataset.identifier, afterIdentifier));
if (element != null) {
internalAddItem(item, itm -> insertAfter(itm.element(), element));
}
return this;
}
public Navigation insertGroupBefore(NavigationGroup group, String beforeIdentifier) {
HTMLElement element = Elements.find(itemsContainer, By.data(Dataset.identifier, beforeIdentifier));
if (element != null) {
internalAddGroup(group, grp -> insertBefore(grp.element(), element));
}
return this;
}
public Navigation insertGroupAfter(NavigationGroup group, String afterIdentifier) {
HTMLElement element = Elements.find(itemsContainer, By.data(Dataset.identifier, afterIdentifier));
if (element != null) {
internalAddGroup(group, grp -> insertAfter(grp.element(), element));
}
return this;
}
public Navigation insertGroupBefore(ExpandableNavigationGroup group, String beforeIdentifier) {
HTMLElement element = Elements.find(itemsContainer, By.data(Dataset.identifier, beforeIdentifier));
if (element != null) {
internalAddGroup(group, grp -> insertBefore(grp.element(), element));
}
return this;
}
public Navigation insertGroupAfter(ExpandableNavigationGroup group, String afterIdentifier) {
HTMLElement element = Elements.find(itemsContainer, By.data(Dataset.identifier, afterIdentifier));
if (element != null) {
internalAddGroup(group, grp -> insertAfter(grp.element(), element));
}
return this;
}
// ------------------------------------------------------ builder
public Navigation theme(Brightness theme) {
if (verifyEnum(element(), "theme", theme, dark, light)) {
swap(this, element(), theme, Brightness.values());
}
return this;
}
@Override
public Navigation that() {
return this;
}
// ------------------------------------------------------ aria
/**
* Aria-label for the back scroll button
*/
public Navigation ariaScrollBackLabel(String label) {
if (scrollBack != null) {
scrollBack.aria(Aria.label, label);
}
return this;
}
/**
* Aria-label for the forward scroll button
*/
public Navigation ariaScrollForwardLabel(String label) {
if (scrollForward != null) {
scrollForward.aria(Aria.label, label);
}
return this;
}
// ------------------------------------------------------ events
public Navigation onSelect(SelectHandler selectHandler) {
this.selectHandler.add(selectHandler);
return this;
}
public Navigation onToggle(ToggleHandler toggleHandler) {
this.toggleHandler.add(toggleHandler);
return this;
}
// ------------------------------------------------------ api
public void select(String itemId) {
select(findItem(itemId), true);
}
public void select(String itemId, boolean fireEvent) {
select(findItem(itemId), fireEvent);
}
public void select(NavigationItem item) {
select(item, true);
}
public void select(NavigationItem item, boolean fireEvent) {
if (item != null) {
unselectAllItems();
item.select();
if (fireEvent) {
selectHandler.forEach(sh -> sh.onSelect(new Event(""), item, true));
}
if (type == expandable) {
unselectAllExpandableGroups();
selectGroup(item.a, fireEvent);
}
} else {
unselectAllItems();
unselectAllExpandableGroups();
}
}
@Override
public Iterator iterator() {
return items.values().iterator();
}
@Override
public int size() {
return items.size();
}
@Override
public boolean isEmpty() {
return items.isEmpty();
}
@Override
public void clear() {
removeChildrenFrom(itemsContainer);
items.clear();
}
// ------------------------------------------------------ internal
private void internalAddItem(NavigationItem item, Consumer dom) {
items.put(item.identifier(), item);
dom.accept(item);
if (isAttached(element())) {
updateScrollState();
}
}
private void internalAddGroup(NavigationGroup group, Consumer dom) {
groups.put(group.identifier(), group);
dom.accept(group);
}
private void internalAddGroup(ExpandableNavigationGroup group, Consumer dom) {
group.collapse(); // all groups are collapsed by default
expandableGroups.put(group.identifier(), group);
if (toggleHandler != null) {
group.toggleHandler.addAll(toggleHandler);
}
dom.accept(group);
}
private void unselectAllItems() {
// remove the current modifier from all navigation item elements
for (HTMLElement element : findAll(A_NAV_LINK_CURRENT)) {
element.classList.remove(modifier(current));
element.removeAttribute(Aria.current);
}
}
private void unselectAllExpandableGroups() {
// remove the current modifier from all expandable group elements
for (HTMLElement element : findAll(LI_NAV_ITEM_EXPANDABLE.and(By.classname(modifier(current))))) {
element.classList.remove(modifier(current));
}
}
private void selectGroup(HTMLElement element, boolean fireEvent) {
HTMLElement li = Elements.closest(element, LI_NAV_ITEM_EXPANDABLE);
if (li != null) {
li.classList.add(modifier(current));
String groupId = li.dataset.get(Dataset.identifier);
ExpandableNavigationGroup group = findGroup(groupId);
if (group != null) {
group.expand();
if (fireEvent) {
toggleHandler.forEach(sh -> sh.onToggle(new Event(""), group, true));
}
}
// select the parent group (if any)
Element parent = li.parentElement;
if (parent instanceof HTMLElement) {
selectGroup((HTMLElement) parent, fireEvent);
}
}
}
private NavigationItem findItem(String id) {
NavigationItem item = items.get(id);
if (item == null) {
if (type == grouped) {
for (NavigationGroup group : groups.values()) {
item = group.findItem(id);
if (item != null) {
break;
}
}
} else if (type == expandable) {
for (ExpandableNavigationGroup group : expandableGroups.values()) {
item = group.findItem(id);
if (item != null) {
break;
}
}
}
}
return item;
}
private ExpandableNavigationGroup findGroup(String id) {
ExpandableNavigationGroup group = expandableGroups.get(id);
if (group == null) {
for (ExpandableNavigationGroup nestedGroup : expandableGroups.values()) {
group = nestedGroup.findGroup(id);
if (group != null) {
break;
}
}
}
return group;
}
private void updateScrollState() {
// debounce scroll event!
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout((__) -> {
boolean overflowOnLeft = !isElementInView(itemsContainer,
((HTMLElement) itemsContainer.firstElementChild), false);
boolean overflowOnRight = !isElementInView(itemsContainer,
((HTMLElement) itemsContainer.lastElementChild), false);
enableScrollButtons.change(overflowOnLeft || overflowOnRight);
disableBackScrollButton.change(!overflowOnLeft);
disableForwardScrollButton.change(!overflowOnRight);
}, 100);
}
private void hideScrollButtons() {
if (!enableScrollButtons.get() && !showScrollButtons.get() && renderScrollButtons.get()) {
renderScrollButtons.change(false);
}
}
private void scrollBack() {
HTMLElement firstElementInView = null;
HTMLElement lastElementOutOfView = null;
NodeList children = itemsContainer.childNodes;
for (int i = 0; i < children.length && firstElementInView == null; i++) {
HTMLElement child = (HTMLElement) children.item(i);
if (isElementInView(itemsContainer, child, false)) {
firstElementInView = child;
lastElementOutOfView = (HTMLElement) children.item(i - 1);
}
}
if (lastElementOutOfView != null) {
if (languageDirection(element()) == LanguageDirection.ltr) {
itemsContainer.scrollLeft -= lastElementOutOfView.scrollWidth;
} else {
itemsContainer.scrollLeft += lastElementOutOfView.scrollWidth;
}
}
}
private void scrollForward() {
HTMLElement lastElementInView = null;
HTMLElement firstElementOutOfView = null;
NodeList children = itemsContainer.childNodes;
for (int i = children.length - 1; i >= 0 && lastElementInView == null; i--) {
HTMLElement child = (HTMLElement) children.item(i);
if (isElementInView(itemsContainer, child, false)) {
lastElementInView = child;
firstElementOutOfView = (HTMLElement) children.item(i + 1);
}
}
if (firstElementOutOfView != null) {
if (languageDirection(element()) == LanguageDirection.ltr) {
itemsContainer.scrollLeft += firstElementOutOfView.scrollWidth;
} else {
itemsContainer.scrollLeft -= firstElementOutOfView.scrollWidth;
}
}
}
}