diff --git a/config/services.yaml b/config/services.yaml index 767af26..3854036 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/docs/Navigation/userMenu.md b/docs/Navigation/userMenu.md new file mode 100644 index 0000000..aafd909 --- /dev/null +++ b/docs/Navigation/userMenu.md @@ -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 diff --git a/docs/index.md b/docs/index.md index cbbc056..acbd1fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/src/Component/Navigation/UserMenu.php b/src/Component/Navigation/UserMenu.php new file mode 100644 index 0000000..2010064 --- /dev/null +++ b/src/Component/Navigation/UserMenu.php @@ -0,0 +1,184 @@ + + * 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> + */ + public array $items; + + public function __construct( + private readonly RequestStack $requestStack, + ) { + } + + /** + * @param array $data + * + * @return array + */ + #[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> $items + * + * @return array> + */ + 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; + } +} diff --git a/templates/navigation/menu.html.twig b/templates/navigation/menu.html.twig index c5855a8..c94722d 100644 --- a/templates/navigation/menu.html.twig +++ b/templates/navigation/menu.html.twig @@ -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') %} -