Skip to content

Commit

Permalink
Merge pull request #14 from cameronmurphy/feature/12-active-state
Browse files Browse the repository at this point in the history
Active state
  • Loading branch information
cameronmurphy authored Mar 30, 2024
2 parents 43f4943 + 77514d8 commit 8b62c5a
Show file tree
Hide file tree
Showing 15 changed files with 142 additions and 61 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

1.6.0
-----
- Added support for `active` styles, fixes #12.
- Added support for Symfony 7.

1.5.0
-----
- Added ability to negate roles with an exclamation mark, fixes #4.
Expand Down
2 changes: 2 additions & 0 deletions DependencyInjection/BootstrapMenuExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public function load(array $configs, ContainerBuilder $container): void
$config = $this->processConfiguration($configuration, $configs);

$container->setParameter('bootstrap_menu.version', $config['version']);
$container->setParameter('bootstrap_menu.dropdown_active_style', $config['dropdown_active_style']);
$container->setParameter('bootstrap_menu.dropdown_item_active_style', $config['dropdown_item_active_style']);
$container->setParameter('bootstrap_menu.menus', $config['menus']);
}
}
6 changes: 6 additions & 0 deletions DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ private function addMenusNode(ArrayNodeDefinition $rootNode): void
->min(4)
->defaultValue(5)
->end()
->booleanNode('dropdown_active_style')
->defaultValue(false)
->end()
->booleanNode('dropdown_item_active_style')
->defaultValue(false)
->end()
->arrayNode('menus')
->requiresAtLeastOneElement()
->disallowNewKeysInSubsequentConfigs()
Expand Down
34 changes: 26 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Bootstrap Menu Bundle
[![CircleCI](https://dl.circleci.com/status-badge/img/circleci/34vMYXApuzs6spAruM7wQy/JcJXpZqKaYA52SDiAunsPM/tree/main.svg?style=svg&circle-token=691995787d3ce0e1396b73f77a37252166a990c2)](https://dl.circleci.com/status-badge/redirect/circleci/34vMYXApuzs6spAruM7wQy/JcJXpZqKaYA52SDiAunsPM/tree/main)

A simple [Symfony](https://symfony.com/) bundle for defining your application's menus in configuration and rendering them to work with
[Bootstrap](https://getbootstrap.com/)'s [Navbar](https://getbootstrap.com/docs/5.0/components/navbar/) component. This bundle supports
[Bootstrap](https://getbootstrap.com/)'s [Navbar](https://getbootstrap.com/docs/5.2/components/navbar/) component. This bundle supports
Bootstrap versions 4 and 5.

Installation
Expand All @@ -16,10 +16,12 @@ Usage
-----
Your menus are defined in `config/packages/bootstrap_menu.yaml`.

Below is a very simple menu called `main` with with only a single 'Logout' link.
Below is a very simple menu called `main` with only a single 'Logout' link.
```yaml
bootstrap_menu:
version: 5 # Optional, defaults to Bootstrap 5
dropdown_active_style: false # Optional, defaults to false
dropdown_item_active_style: false # Optional, defaults to false
menus:
main:
items:
Expand All @@ -29,7 +31,7 @@ bootstrap_menu:
```
Then within your template you can render your menu in a Navbar by passing the name of your menu to `render_bootstrap_menu`. This markup is
taken from the [Bootstrap Navbar Fixed example](https://getbootstrap.com/docs/5.0/examples/navbar-fixed/). The Bootstrap 4 version is
taken from the [Bootstrap Navbar Fixed example](https://getbootstrap.com/docs/5.2/examples/navbar-fixed/). The Bootstrap 4 version is
[here](https://getbootstrap.com/docs/4.6/examples/navbar-fixed)
```twig
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
Expand All @@ -50,7 +52,7 @@ taken from the [Bootstrap Navbar Fixed example](https://getbootstrap.com/docs/5.
```
Result:

![Example 1](https://user-images.githubusercontent.com/1300032/54358791-4f00fb00-46b5-11e9-817c-4b8101305a2b.png)
![Example 1](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/128ec4b0-2f26-40d9-af14-c7ef5efc0f9d)

### Route parameters
Perhaps your route requires parameters. You can also specify these.
Expand Down Expand Up @@ -98,7 +100,7 @@ bootstrap_menu:
```
Result:

![Example 2](https://user-images.githubusercontent.com/1300032/54359374-9fc52380-46b6-11e9-9c0c-bea934d9f0a2.png)
![Example 2](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/88b57cd9-2d1d-4389-870c-c3fbce0613d7)

#### Dividers
Dropdowns can also contain [Dividers](https://getbootstrap.com/docs/4.3/components/dropdowns/#dividers) to separate groups of related menu
Expand All @@ -122,7 +124,7 @@ bootstrap_menu:
```
Result:

![Example 3](https://user-images.githubusercontent.com/1300032/54359921-bf108080-46b7-11e9-8101-faf2526697ef.png)
![Example 3](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/06ffb618-32d6-48ad-9641-38a59ccade8c)

#### Headers
Dividers that also contain a `label` become [Headers](https://getbootstrap.com/docs/4.3/components/dropdowns/#headers).
Expand Down Expand Up @@ -150,7 +152,7 @@ bootstrap_menu:
```
Result:

![Example 4](https://user-images.githubusercontent.com/1300032/54360188-73120b80-46b8-11e9-9af7-6150182b8243.png)
![Example 4](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/6363a9b3-a5c4-4d39-9f04-a569dc286517)

#### Security
Certain parts of the menu may be locked down by role. This following example only allows administrators to change their password.
Expand Down Expand Up @@ -179,7 +181,7 @@ bootstrap_menu:
```
For a user without `ROLE_ADMINISTRATOR` they would see:

![Example 5](https://user-images.githubusercontent.com/1300032/54361573-60e59c80-46bb-11e9-89db-669a02f4b82b.png)
![Example 5](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/aa90ef88-72de-4b27-b474-096579f539b1)

The reason for this is Bootstrap Menu Bundle intelligently prunes Dropdowns to remove unnecessary Dividers. Because the user is not
permitted to see any items between 'Password Stuff' and 'Other Stuff', the 'Password Stuff' Divider is also pruned.
Expand Down Expand Up @@ -218,3 +220,19 @@ bootstrap_menu:
label: 'Users'
route: 'app_user_list'
```

#### Active styles
Out of the box, top level menu items are given the [`active` class](https://getbootstrap.com/docs/5.2/components/navbar/#nav) when the
current route matches the menu item's route. In this example we're on the 'Reports' page.

![Example 6](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/8322b338-3f0d-4940-ab61-900e59886714)

##### Dropdown active styles
It's not documented, but the `active` class also works on dropdowns and dropdown items. To enable for dropdowns, set `dropdown_active_style`
to `true` in the config.

![Example 7](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/fb9c2645-9826-48c3-9ac4-2c6406e09bab)

To enable for dropdown items, set `dropdown_item_active_style` to true.

![Example 8](https://github.com/cameronmurphy/bootstrap-menu-bundle/assets/1300032/92e12bd9-ab28-44eb-ad2a-91fd3fab2a57)
2 changes: 2 additions & 0 deletions Resources/config/bootstrap_menu.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<service id="camurphy.bootstrap_menu.twig.menu_extension" class="Camurphy\BootstrapMenuBundle\Twig\Extension\MenuExtension" public="false">
<argument>%bootstrap_menu.menus%</argument>
<argument>%bootstrap_menu.version%</argument>
<argument>%bootstrap_menu.dropdown_active_style%</argument>
<argument>%bootstrap_menu.dropdown_item_active_style%</argument>
<tag name="twig.extension" />
</service>
</services>
Expand Down
16 changes: 9 additions & 7 deletions Resources/views/dropdown.html.twig
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
{% set data_attribute_name = 'data-bs-toggle' %}
{% apply spaceless %}
{% set data_attribute_name = 'data-bs-toggle' %}

{% if bootstrap_version == 4 %}
{% set data_attribute_name = 'data-toggle' %}
{% endif %}
{% if bootstrap_version == 4 %}
{% set data_attribute_name = 'data-toggle' %}
{% endif %}

{% set is_active = dropdown_active_style and app.request.attributes.get('_route') in menu_item.items|map(item => item.route is defined ? item.route : null) %}

{% apply spaceless %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" {{ data_attribute_name }}="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="nav-link dropdown-toggle{% if is_active %} active{% endif %}"{% if is_active %} aria-current="page"{% endif %} href="#" role="button" {{ data_attribute_name }}="dropdown" aria-haspopup="true" aria-expanded="false">
{{- menu_item.label -}}
</a>
<div class="dropdown-menu">
{% for menu_item in menu_item.items %}
{% set menu_item = menu_item|merge({ 'index': loop.index0 }) %}
{% include '@BootstrapMenu/dropdown_item.html.twig' with { menu_item } %}
{% include '@BootstrapMenu/dropdown_item.html.twig' with { menu_item, dropdown_item_active_style } %}
{% endfor %}
</div>
</li>
Expand Down
3 changes: 2 additions & 1 deletion Resources/views/dropdown_item.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
<h6 class="dropdown-header">{{ menu_item.label }}</h6>
{% endif %}
{% else %}
<a class="dropdown-item" {% include '@BootstrapMenu/href.html.twig' with { menu_item } %}>
{% set is_active = dropdown_item_active_style and menu_item.route is defined and app.request.attributes.get('_route') == menu_item.route %}
<a class="dropdown-item{% if is_active %} active{% endif %}"{% if is_active %} aria-current="page"{% endif %} {% include '@BootstrapMenu/href.html.twig' with { menu_item } %}>
{{- menu_item.label -}}
</a>
{% endif %}
Expand Down
11 changes: 6 additions & 5 deletions Resources/views/link.html.twig
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{% apply spaceless %}
{% if menu_item.label is defined and (menu_item.route is defined or menu_item.url is defined) %}
<li class="nav-item">
<a class="nav-link" {% include '@BootstrapMenu/href.html.twig' with { menu_item } %}>
{{- menu_item.label -}}
</a>
</li>
{% set is_active = app.request.attributes.get('_route') == menu_item.route %}
<li class="nav-item">
<a class="nav-link{% if is_active %} active{% endif %}"{% if is_active %} aria-current="page"{% endif %} {% include '@BootstrapMenu/href.html.twig' with { menu_item } %}>
{{- menu_item.label -}}
</a>
</li>
{% endif %}
{% endapply %}
74 changes: 59 additions & 15 deletions Tests/Twig/Extension/MenuExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
use Spatie\Snapshots\MatchesSnapshots;
use Symfony\Bridge\Twig\Extension\SecurityExtension;
use Twig\Environment as TwigEnvironment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction;

/**
* @internal
*
* @coversNothing
*/
final class MenuExtensionTest extends TestCase
Expand Down Expand Up @@ -94,7 +98,7 @@ final class MenuExtensionTest extends TestCase
'roles' => [],
],
'link_1' => [
'label' => 'Link 1 (this should render a nav-link)',
'label' => 'Link 1 (this should render an active nav-link)',
'route' => 'app_link_1_route',
'route_parameters' => [],
'roles' => [],
Expand Down Expand Up @@ -209,9 +213,9 @@ protected function setUp(): void
}

/**
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
public function testRenderMenuBootstrap4(): void
{
Expand All @@ -225,9 +229,9 @@ public function testRenderMenuBootstrap4(): void
}

/**
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
public function testRenderMenuBootstrap5(): void
{
Expand All @@ -241,20 +245,62 @@ public function testRenderMenuBootstrap5(): void
}

/**
* @throws \Twig\Error\LoaderError
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
private function mockTwigEnvironment(): MockObject
public function testRenderMenuBootstrap5WithActiveDropdownStyles(): void
{
/** @var TwigEnvironment $mockTwigEnvironment */
$mockTwigEnvironment = $this->mockTwigEnvironment('app_dropdown_item_8_route');

$menuExtension = new MenuExtension(self::$mockMenus, 5, true, true);
$menu = $menuExtension->renderMenu($mockTwigEnvironment, 'main');

$this->assertMatchesSnapshot($menu);
}

/**
* @param mixed $activeRoute
*
* @throws LoaderError
*/
private function mockTwigEnvironment(string $activeRoute = 'app_link_1_route'): MockObject
{
$loader = new FilesystemLoader([], $this->rootPath);
$loader->addPath('Resources/views', 'BootstrapMenu');

/** @var MockObject|\Twig\Environment $twigMock */
/** @var MockObject|TwigEnvironment $twigMock */
$twigMock = $this->getMockBuilder(TwigEnvironment::class)
->setConstructorArgs([$loader])
->setMethods(['getExtension'])
->getMock()
;

$twigMock->addGlobal('app', [
'request' => [
'attributes' => new class($activeRoute) {
private $activeRoute;

public function __construct(string $activeRoute)
{
$this->activeRoute = $activeRoute;
}

public function get(string $name): ?string
{
switch ($name) {
case '_route':
return $this->activeRoute;

default:
return null;
}
}
},
],
]);

$securityExtensionMock = $this->getMockBuilder(SecurityExtension::class)
->disableOriginalConstructor()
->setMethods(['isGranted', 'getDefaultStrategy'])
Expand All @@ -263,18 +309,16 @@ private function mockTwigEnvironment(): MockObject

$securityExtensionMock
->method('isGranted')
->willReturnCallback(function ($expression) {
return \in_array($expression, ['ROLE_USER', 'ROLE_SUPPORT'], true);
})
->willReturnCallback(static fn ($expression) => \in_array($expression, ['ROLE_USER', 'ROLE_SUPPORT'], true))
;

$twigMock
->expects(static::any())
->expects(self::any())
->method('getExtension')
->willReturn($securityExtensionMock)
;

$pathFunction = new TwigFunction('path', function ($route, $routeParameters = []): string {
$pathFunction = new TwigFunction('path', static function ($route, $routeParameters = []): string {
$path = '/' . str_replace('_', '-', $route);

if (\count($routeParameters) > 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1 @@


<li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 1</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-2-route?test=123">Dropdown Item 2 (should render a dropdown-item)</a></div></li>

<li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 2</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-item-3-route">Dropdown Item 3 (should render a dropdown-item because user has permission)</a><a class="dropdown-item" href="https://disney.com">Dropdown Item 4 (should render a dropdown-item linking to Disney)</a></div></li><li class="nav-item"><a class="nav-link" href="/app-link-1-route">Link 1 (this should render a nav-link)</a></li>

<li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 4</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-item-8-route">Dropdown Item 8</a><div class="dropdown-divider"></div><h6 class="dropdown-header">Divider 2 (should render a dropdown-header)</h6><a class="dropdown-item" href="/app-dropdown-item-9-route">Dropdown Item 9</a><div class="dropdown-divider"></div><a class="dropdown-item" href="/app-dropdown-item-10-route">Dropdown Item 10</a><a class="dropdown-item" href="/app-dropdown-item-11-route">Dropdown Item 11</a><div class="dropdown-divider"></div><h6 class="dropdown-header">Divider 5</h6><a class="dropdown-item" href="/app-dropdown-item-13-route">Dropdown Item 13</a></div></li>
<li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 1</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-2-route?test=123">Dropdown Item 2 (should render a dropdown-item)</a></div></li><li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 2</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-item-3-route">Dropdown Item 3 (should render a dropdown-item because user has permission)</a><a class="dropdown-item" href="https://disney.com">Dropdown Item 4 (should render a dropdown-item linking to Disney)</a></div></li><li class="nav-item"><a class="nav-link active" aria-current="page" href="/app-link-1-route">Link 1 (this should render an active nav-link)</a></li><li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 4</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-item-8-route">Dropdown Item 8</a><div class="dropdown-divider"></div><h6 class="dropdown-header">Divider 2 (should render a dropdown-header)</h6><a class="dropdown-item" href="/app-dropdown-item-9-route">Dropdown Item 9</a><div class="dropdown-divider"></div><a class="dropdown-item" href="/app-dropdown-item-10-route">Dropdown Item 10</a><a class="dropdown-item" href="/app-dropdown-item-11-route">Dropdown Item 11</a><div class="dropdown-divider"></div><h6 class="dropdown-header">Divider 5</h6><a class="dropdown-item" href="/app-dropdown-item-13-route">Dropdown Item 13</a></div></li>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 1</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-2-route?test=123">Dropdown Item 2 (should render a dropdown-item)</a></div></li><li class="nav-item dropdown"><a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 2</a><div class="dropdown-menu"><a class="dropdown-item" href="/app-dropdown-item-3-route">Dropdown Item 3 (should render a dropdown-item because user has permission)</a><a class="dropdown-item" href="https://disney.com">Dropdown Item 4 (should render a dropdown-item linking to Disney)</a></div></li><li class="nav-item"><a class="nav-link" href="/app-link-1-route">Link 1 (this should render an active nav-link)</a></li><li class="nav-item dropdown"><a class="nav-link dropdown-toggle active" aria-current="page" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown Menu 4</a><div class="dropdown-menu"><a class="dropdown-item active" aria-current="page" href="/app-dropdown-item-8-route">Dropdown Item 8</a><div class="dropdown-divider"></div><h6 class="dropdown-header">Divider 2 (should render a dropdown-header)</h6><a class="dropdown-item" href="/app-dropdown-item-9-route">Dropdown Item 9</a><div class="dropdown-divider"></div><a class="dropdown-item" href="/app-dropdown-item-10-route">Dropdown Item 10</a><a class="dropdown-item" href="/app-dropdown-item-11-route">Dropdown Item 11</a><div class="dropdown-divider"></div><h6 class="dropdown-header">Divider 5</h6><a class="dropdown-item" href="/app-dropdown-item-13-route">Dropdown Item 13</a></div></li>
Loading

0 comments on commit 8b62c5a

Please sign in to comment.