Spring Boot (5) Bootstrap 5 multilevel dropdown menu
0. Introduction
This post is based on
These components are used
- 2 classes for defining menus (including the configuration file for reading the YAML file)
- A YAML file for storing the menu definition
- A controller of the URL used by the menu
- A ThymeLeaf file and 2 fragments
- CSS file
1. The Menu classes (and the configuration class)
The menu class has the component of a menu, indicating the name, the href and so on
package ximo.menus; import java.util.ArrayList; import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @AllArgsConstructor @NoArgsConstructor public class Menu { private String id; private String name; private List<String> idFills; private String href="#"; private List<Menu> fills=new ArrayList<Menu>(); public boolean hasChildren() { if (fills==null) return false; return fills.size()>0; } }
The configuration class for reading the YAML uses annotations for localizing the YAML file and a prefix that must have the YAML file (in this case the prefix is "menus")
package ximo.menus; import java.util.List; import java.util.stream.Collectors; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import lombok.Getter; import lombok.Setter; import ximo.yaml.YamlPropertySourceFactory; /** * Reads the file resources/menus/negociats.yaml into a list of Negociat * @author eduard * */ @Configuration @ConfigurationProperties(prefix = "menus") //OJO: Debe coincidir con el comienzo del fichero de propiedades !!!! @PropertySource(value = "classpath:menus/menus.yml", factory = YamlPropertySourceFactory.class) @Getter @Setter //@NoArgsConstructor @AllArgsConstructor public class Menus implements InitializingBean{ private List<Menu> items; /** * Fills the children list */ @Override public void afterPropertiesSet() throws Exception { for (Menu menu: items) { if (menu.getIdFills()!=null) { List<Menu> lstChild= items.stream() .filter(mnu -> menu.getIdFills().contains(mnu.getId())) .collect(Collectors.toList()); menu.setFills(lstChild); } } } /** * list the menus whose id<100 * @return */ public List<Menu> getNegociats () { List<Menu> lstNegociats= items.stream() .filter(mnu-> mnu.getId().trim().length()==1) .collect(Collectors.toList()); return lstNegociats; } }
2. The YAML file
This file is stored in the resources/menus/menus.yml file
Note the prefix "menus" in the beginning of the file that must match with the @ConfigurationProperties (prefix="menus").
Note also that the nodes that have children do not have "href" property,
menus: items: #--- Negociats: Nivell 0 - { id: 1, name: Secretaria, idFills: [1-1, 1-2] } - { id: 2, name: Estadística, idFills: [1-1-1, 1-1-2] } - { id: 3, name: RRHH } - { id: 4, name: Serveis } - { id: 5, name: Urbanisme } - { id: 6, name: Àrea Econòmica } - { id: 7, name: Policia } #-------- Menus nivel>0 - { id: 1-1, name: Format ENI, idFills: [1-1-1, 1-1-2] } - { id: 1-1-1, name: Sedipualba-ENI, idFills: [1-1-1-1, 1-1-1-2, 1-1-1-3, 1-1-1-4] } - { id: 1-1-1-1, name: 1-Generar documents ENI, href: /process01 } - { id: 1-1-1-2, name: 2-Generar expedient ENI, href: '/process02?APPLE=apple&BANANA=banana' } - { id: 1-1-1-3, name: 3-Signar Contingut expedient ENI, href: '/process03?field={"name":"myname", "value":"myvalue"}&field={"name":"year","value":"2023","component":"PASSWORD"}' } - { id: 1-1-1-4, name: 4-Signar PDF index, href: '/process04?process=Process01&field={"name":"myname", "value":"myvalue"}&field={"name":"year","value":"2023","component":"PASSWORD"}' } - { id: 1-1-2, name: Gexflow-ENI, idFills: [1-1-2-1, 1-1-2-2, 1-1-2-3] } - { id: 1-1-2-1, name: 1-Generar doc i expedients ENI, href: /signExpENI01 } - { id: 1-1-2-2, name: 2-Signar Contingut expedient ENI, href: /signExpENI01 } - { id: 1-1-2-3, name: 3-Signar PDF index, href: /signExpENI01 } - { id: 1-2, name: proves, idFills: [1-2-1] } - { id: 1-2-1, name: subproves, idFills: [1-2-1-1] } - { id: 1-2-1-1, name: sub-subproves, idFills: [1-2-1-1-1] } - { id: 1-2-1-1-1, name: sub-sub-subproves, href: '/process03?field={"name":"myname", "value":"myvalue"}&field={"name":"year","value":"2023","component":"PASSWORD"}' }
3. The controller
Simply accepts the general URL "/" and passes the menu structure to the model that is referenced by the Thymeleaf template home_page.html
package ximo.controllers; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import ximo.menus.Menus; import jakarta.servlet.http.HttpServletRequest; @Controller public class MenuController2 { @Autowired private Menus menus; @GetMapping("/") public String home(HttpServletRequest request, Model model) { model.addAttribute("negociats", menus.getNegociats()); return "home_page"; } }
4. The Thymeleaf template and fragments
The Thymeleaf template "home_page.html" is. Only note the use of multilevel2.css and the inclusion of the fragment navbar
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Edu01 app</title> <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/5.2.3/css/bootstrap.min.css}" /> <script th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}"></script> <!-- multilevel --> <link rel="stylesheet" type="text/css" href="/css/edu/multilevel2.css"> </head> <body> <div th:replace="~{fragments/navbar :: navbar}"> hola </div> </body> </html>
2 fragments are used and both are in the file fragments/navbar.html
Note that the last fragment subnode(node) accepts one parameter and is recursive. See Faraj Farook
Note the green part that differentiates nodes with children(submenus without an action -href- and nodes without children that represent an action with -href-
For executing an action, the last post has details about it.
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/5.2.3/css/bootstrap.min.css}" /> <!--Bootstrap JS--> <script th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}"></script> </head> <body> <nav class="navbar navbar-expand-lg bg-light" th:fragment="navbar"> <div class="container-fluid"> <a class="navbar-brand" href="#">Menú dUtilitats</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarScroll" aria-controls="navbarScroll" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarScroll"> <div th:each="negociat : ${negociats}" class="navbar-nav me-auto my-2 my-lg-0 navbar-nav-scroll" data-bs-toggle="outside" style="--bs-scroll-height: 100px;"> <div class="nav-item dropdown"> <a th:if="${negociat.hasChildren()}" th:text="${negociat.name}" class="nav-link dropdown-toggle" data-bs-auto-close="outside" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> negociats </a> <a th:if="${!negociat.hasChildren()}" th:text="${negociat.name}" class="nav-link" th:href="${negociat.href}" role="button" aria-expanded="false"> negociats </a> <!-- <ul th:each="menu : ${negociat.fills}" class="dropdown-menu"> --> <div class="dropdown-menu dropend" > <div th:each="menu : ${negociat.fills}"> <a th:if="${menu.hasChildren()}" th:text="${menu.name}" class="dropdown-item dropdown-toggle" data-bs-toggle="dropdown" href="#"> menu </a> <a th:if="${!menu.hasChildren()}" th:text="${menu.name}" class="dropdown-item" th:href="${menu.href}"> menu </a> <!-- =================================== --> <div th:replace="~{fragments/navbar :: submenu(node=${menu})}"> hola </div> <!-- =================================== --> </div> </div> </div> </div> </div> </div> </nav> <!-- ======================================================== --> <div class="dropdown-menu submenu" th:fragment="submenu(node)" th:unless="${!node.hasChildren()}"> <div th:each="child : ${node.fills}" th:inline="text"> <a th:if="${child.hasChildren()}" th:text="${child.name}" class="dropdown-item dropdown-toggle" data-bs-toggle="dropdown" href="#"> submenu </a> <a th:if="${!child.hasChildren()}" th:text="${child.name}" class="dropdown-item" th:href="${child.href}"> submenu </a> <div th:replace="this::submenu(${child})"></div> </div> </div> </body> </html>
5. CSS File
Note the selectors "." ">" ":" "::after"
This file is
.dropdown div { position: relative; } .dropdown-menu .submenu { display: none; position: absolute; left: 100%; top: -0px; } .dropdown-menu>div:hover>.submenu { display: block; } /* ============ small devices ============ */ @media (max-width: 991px) { .dropdown-item::after { transform: rotate(90deg); } } /* ============ small devices .end// ============ */
Comentarios
Publicar un comentario