Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ services:
Enabel\Ux\Component\Navigation\Navbar:
tags:
- { name: 'twig.component', key: 'Enabel:Ux:Navbar', template: '@EnabelUx/navigation/navbar.html.twig', expose_public_props: true }

Enabel\Ux\Component\Navigation\UserMenu:
arguments:
- '@request_stack'
tags:
- { name: 'twig.component', key: 'Enabel:Ux:UserMenu', template: '@EnabelUx/navigation/user_menu.html.twig', expose_public_props: true }

Enabel\Ux\Component\Navigation\Tab:
tags:
Expand Down
135 changes: 135 additions & 0 deletions docs/Navigation/userMenu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# UserMenu Component

## Description

A ready-to-use Bootstrap user menu for the navbar. Renders a circular avatar (photo or auto-derived colored initials) that opens a right-aligned dropdown with the user name as header and a configurable list of items (links, dividers, headers).

This component is intended to be nested inside a [Menu](menu.md) component aligned to the end of the [Navbar](navbar.md).

**Component Nesting:**
- **Navbar** is the top-level container
- Contains one or more **Menu** components inside its `content` block
- A **Menu** can contain a **UserMenu** (this component) alongside [MenuItem](menuItem.md) components

## Parameters

| Parameter | Type | Description | Default |
|:----------------|:--------------------|:--------------------------------------------------------------------------------------------------|:-----------------------|
| `name` | `string` (required) | The user display name. Used for the dropdown header, the toggle `aria-label`, and to derive default initials/color | — |
| `image` | `?string` | URL or data-URI of the user picture. When set, the avatar renders the image instead of initials | `null` |
| `initials` | `?string` | Override of the auto-derived initials | derived from `name` |
| `initialsColor` | `?string` | Override of the auto-derived background color (CSS color). Used only when no `image` is provided | derived from `name` |
| `dropdownAlign` | `string` | Dropdown alignment, `start` or `end` | `'end'` |
| `hideName` | `bool` | Hide the user name next to the avatar in the toggle (the name is still in the dropdown header) | `true` |
| `items` | `array` | Dropdown items (each with `label`, `link`, `icon`, `divider`, `header`, `active`, `disabled`, `visible`) | `[]` |

## Initials derivation

When `initials` is not provided, they are derived from `name`:
- Two or more words → first letter of the first word + first letter of the last word (e.g., `Damien Lagae` → `DL`, `John Ronald Reuel Tolkien` → `JT`)
- One word → first two characters of the word (e.g., `Damien` → `DA`)
- Always uppercase, multibyte-safe (e.g., `éric Dübois` → `ÉD`)
- Empty/whitespace name → empty string

## Color derivation

When `initialsColor` is not provided, a deterministic color is picked from a curated palette using a CRC32 hash of `name`. The same name always gets the same color, which keeps avatars visually stable across pages and sessions.

## Visible flag for items

Each dropdown item supports a `visible: bool` flag. When `visible` is explicitly `false`, the item is filtered out before rendering. This lets you express conditional items (e.g., admin links protected by a permission check) directly in the items array, without surrounding `{% if %}` blocks:

```twig
{{ component('Enabel:Ux:UserMenu', {
name: app.user.displayName,
items: [
{label: 'Profile', link: path('app_profile'), icon: 'bi:person-circle'},
{label: 'Admin', link: path('admin_dashboard'), icon: 'bi:gear-fill', visible: is_granted('ROLE_ADMIN')},
{divider: true},
{label: 'Logout', link: path('app_logout'), icon: 'bi:box-arrow-right'},
],
}) }}
```

## Automatic active detection

Same behavior as [MenuItem](menuItem.md): each item with a `link` is automatically marked active when its link matches the current request path (exact match, or prefix match for non-root paths). Manual `active` always overrides auto-detection. Dividers and headers are skipped.

## Usage

### Minimal usage (auto-derived initials and color)

```twig
{% component 'Enabel:Ux:Navbar' with { name: 'My App' } %}
{% block content %}
{% component 'Enabel:Ux:Menu' with { align: 'end' } %}
{% block content %}
{{ component('Enabel:Ux:UserMenu', {
name: app.user.displayName,
items: [
{label: 'Profile', link: path('app_profile'), icon: 'bi:person-circle'},
{label: 'Logout', link: path('app_logout'), icon: 'bi:box-arrow-right'},
],
}) }}
{% endblock %}
{% endcomponent %}
{% endblock %}
{% endcomponent %}
```

### With a profile picture

```twig
{{ component('Enabel:Ux:UserMenu', {
name: app.user.displayName,
image: app.user.profilePictureDataUri,
items: [
{label: 'Profile', link: path('app_profile'), icon: 'bi:person-circle'},
{label: 'Logout', link: path('app_logout'), icon: 'bi:box-arrow-right'},
],
}) }}
```

### With pre-computed initials and color

If your `User` entity already exposes initials and a brand color, override the derivation:

```twig
{{ component('Enabel:Ux:UserMenu', {
name: app.user.displayName,
initials: app.user.initials,
initialsColor: app.user.avatarColor,
items: [...],
}) }}
```

### Conditional items

```twig
{{ component('Enabel:Ux:UserMenu', {
name: app.user.displayName,
items: [
{label: 'Profile', link: path('app_profile'), icon: 'bi:person-circle'},
{label: 'Configuration', link: path('admin_dashboard'), icon: 'bi:gear-fill', visible: is_granted('ROLE_ADMIN')},
{divider: true},
{label: 'Logout', link: path('app_logout'), icon: 'bi:box-arrow-right'},
],
}) }}
```

### Show the user name next to the avatar

```twig
{{ component('Enabel:Ux:UserMenu', {
name: app.user.displayName,
hideName: false,
items: [...],
}) }}
```

## See Also

- [Navbar Component](navbar.md) - Main navigation container
- [Menu Component](menu.md) - Container for organizing navigation items
- [MenuItem Component](menuItem.md) - Individual navigation links or dropdowns
- [LocaleSwitcher Component](localeSwitcher.md) - Locale switcher dropdown
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ https://github.com/Enabel/Ux
- [Navbar](Navigation/navbar.md) - Main navigation container with logo, name, and link configuration
- [Menu](Navigation/menu.md) - Menu container for organizing navigation items
- [MenuItem](Navigation/menuItem.md) - Individual navigation links or items
- [UserMenu](Navigation/userMenu.md) - User menu with avatar (photo or auto-derived colored initials) and dropdown
- [LocaleSwitcher](Navigation/localeSwitcher.md) - A locale switcher dropdown for Bootstrap navbar
- [Tab](Navigation/tab.md) - A Bootstrap nav-tabs/nav-pills component for creating tabbed navigation

Expand Down
184 changes: 184 additions & 0 deletions src/Component/Navigation/UserMenu.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

/*
* This file is part of the Enabel UX package.
* Copyright (c) Enabel <https://enabel.be/>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Enabel\Ux\Component\Navigation;

use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\TwigComponent\Attribute\PreMount;

class UserMenu
{
/**
* Predefined palette used to derive a stable background color from the user name
* when no explicit `initialsColor` is provided.
*/
private const COLOR_PALETTE = [
'#0ea5e9',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16',
];

public string $name;
public ?string $image;
public string $initials;
public string $initialsColor;
public string $dropdownAlign;
public bool $hideName;
/**
* @var array<int, array<string, mixed>>
*/
public array $items;

public function __construct(
private readonly RequestStack $requestStack,
) {
}

/**
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
#[PreMount]
public function preMount(array $data): array
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);

$resolved = $resolver->resolve($data);

// Auto-derive initials from name if not explicitly provided
if (null === $resolved['initials']) {
$resolved['initials'] = $this->deriveInitials($resolved['name']);
}

// Auto-derive a stable background color from the name if not explicitly provided
if (null === $resolved['initialsColor']) {
$resolved['initialsColor'] = $this->deriveColor($resolved['name']);
}

// Filter items by visibility and auto-detect active state
$resolved['items'] = $this->processItems($resolved['items']);

return $resolved + $data;
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setIgnoreUndefined();
$resolver->setRequired('name');
$resolver->setDefaults([
'image' => null,
'initials' => null,
'initialsColor' => null,
'dropdownAlign' => 'end',
'hideName' => true,
'items' => [],
]);

$resolver->setAllowedTypes('name', 'string');
$resolver->setAllowedTypes('image', ['string', 'null']);
$resolver->setAllowedTypes('initials', ['string', 'null']);
$resolver->setAllowedTypes('initialsColor', ['string', 'null']);
$resolver->setAllowedTypes('dropdownAlign', 'string');
$resolver->setAllowedTypes('hideName', 'bool');
$resolver->setAllowedTypes('items', 'array');

$resolver->setAllowedValues('dropdownAlign', ['start', 'end']);
}

private function deriveInitials(string $name): string
{
$name = trim($name);
if ('' === $name) {
return '';
}

$parts = preg_split('/\s+/u', $name) ?: [];

if (1 === \count($parts)) {
return mb_strtoupper(mb_substr($parts[0], 0, 2));
}

$first = mb_strtoupper(mb_substr($parts[0], 0, 1));
$last = mb_strtoupper(mb_substr((string) end($parts), 0, 1));

return $first.$last;
}

private function deriveColor(string $name): string
{
if ('' === trim($name)) {
return self::COLOR_PALETTE[0];
}

$hash = crc32($name);

return self::COLOR_PALETTE[$hash % \count(self::COLOR_PALETTE)];
}

/**
* @param array<int, array<string, mixed>> $items
*
* @return array<int, array<string, mixed>>
*/
private function processItems(array $items): array
{
$processed = [];
foreach ($items as $item) {
// Filter out items explicitly marked as not visible
if (isset($item['visible']) && false === $item['visible']) {
continue;
}

// Skip dividers and headers (no active state, no link processing)
if (isset($item['divider']) || isset($item['header'])) {
$processed[] = $item;
continue;
}

// Auto-detect active state if not explicitly set and link is provided
if (!isset($item['active']) && isset($item['link']) && $item['link']) {
$item['active'] = $this->isCurrentRoute((string) $item['link']);
}

$processed[] = $item;
}

return $processed;
}

private function isCurrentRoute(string $href): bool
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return false;
}

$currentPath = $request->getPathInfo();

if ($currentPath === $href) {
return true;
}

if ('/' !== $href && str_starts_with($currentPath, $href)) {
return true;
}

return false;
}
}
2 changes: 1 addition & 1 deletion templates/navigation/menu.html.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{# Menu component #}
{% set alignClass = align == 'center' ? 'mx-auto justify-content-center' : (align == 'end' ? 'd-flex justify-content-end ms-auto' : 'me-auto') %}

<ul class="navbar-nav mb-2 mb-lg-0 {{ alignClass }} {{ attributes.render('class') }}" {{ attributes.defaults({}) }}>
<ul class="navbar-nav align-items-center mb-2 mb-lg-0 {{ alignClass }} {{ attributes.render('class') }}" {{ attributes.defaults({}) }}>
{% block content %}
{% endblock %}
</ul>
40 changes: 40 additions & 0 deletions templates/navigation/user_menu.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{# UserMenu component #}
{% set dropdownAlignClass = dropdownAlign == 'end' ? 'dropdown-menu-end' : 'dropdown-menu-start' %}

<li class="nav-item dropdown {{ attributes.render('class') }}" {{ attributes.defaults({}) }}>
<a class="nav-link dropdown-toggle d-flex align-items-center gap-2 py-1" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{{ name }}">
{% if image %}
<img src="{{ image }}" alt="" class="rounded-circle" style="width: 3rem; height: 3rem; object-fit: cover; border: 2px solid rgba(255, 255, 255, 0.4);">
{% else %}
<span class="rounded-circle d-inline-flex align-items-center justify-content-center text-white fw-bold" style="width: 3rem; height: 3rem; background: {{ initialsColor }}; font-size: 1.125rem;" aria-hidden="true">{{ initials }}</span>
{% endif %}
{% if not hideName %}
<span>{{ name }}</span>
{% endif %}
</a>
<ul class="dropdown-menu {{ dropdownAlignClass }}">
<li><h6 class="dropdown-header">{{ name }}</h6></li>
{% if items|length > 0 %}
<li><hr class="dropdown-divider"></li>
{% for item in items %}
{% if item.divider|default(false) %}
<li><hr class="dropdown-divider"></li>
{% elseif item.header|default(false) %}
<li><h6 class="dropdown-header">{{ item.label }}</h6></li>
{% else %}
<li>
<a class="dropdown-item{% if item.active|default(false) %} active{% endif %}{% if item.disabled|default(false) %} disabled{% endif %}"
{% if item.link|default(null) and not item.disabled|default(false) %}href="{{ item.link }}"{% endif %}
{% if item.active|default(false) %}aria-current="page"{% endif %}
{% if item.disabled|default(false) %}aria-disabled="true" tabindex="-1"{% endif %}>
{% if item.icon|default(null) %}
<twig:ux:icon name="{{ item.icon }}" class="me-2" style="width: 1.1rem; height: 1.1rem;" />
{% endif %}
{{ item.label }}
</a>
</li>
{% endif %}
{% endfor %}
{% endif %}
</ul>
</li>
Loading
Loading