Răsfoiți Sursa

feat(admin-ui): Updated breadcrumbs

David Höck 11 luni în urmă
părinte
comite
529c250113
62 a modificat fișierele cu 1781 adăugiri și 221 ștergeri
  1. 2 4
      packages/admin-ui/src/lib/core/src/components/alerts/alerts.component.html
  2. 2 0
      packages/admin-ui/src/lib/core/src/components/alerts/alerts.component.ts
  3. 23 62
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html
  4. 2 0
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.ts
  5. 9 9
      packages/admin-ui/src/lib/core/src/components/base-nav/base-nav.component.ts
  6. 14 15
      packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html
  7. 16 6
      packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.html
  8. 36 29
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html
  9. 1 0
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts
  10. 3 7
      packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.html
  11. 21 19
      packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.html
  12. 2 0
      packages/admin-ui/src/lib/core/src/core.module.ts
  13. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.html
  14. 0 11
      packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.scss
  15. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.ts
  16. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/logo-full/logo-full.component.svg
  17. 9 0
      packages/admin-ui/src/lib/core/src/shared/components/logo-full/logo-full.component.ts
  18. 41 0
      packages/admin-ui/src/lib/core/src/shared/components/top-bar/top-bar.component.html
  19. 114 0
      packages/admin-ui/src/lib/core/src/shared/components/top-bar/top-bar.component.ts
  20. 63 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  21. 1 1
      packages/admin-ui/src/lib/dashboard/src/widgets/order-chart-widget/order-chart-widget.component.html
  22. 39 21
      packages/admin-ui/src/lib/login/src/components/login/login.component.html
  23. 1 0
      packages/admin-ui/src/lib/static/assets/logo-full.svg
  24. 48 34
      packages/admin-ui/src/lib/static/styles/global/_global.scss
  25. 1 1
      packages/admin-ui/src/lib/static/styles/styles.scss
  26. 24 0
      packages/admin-ui/src/lib/ui/ui-alert-helm/src/index.ts
  27. 21 0
      packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert-description.directive.ts
  28. 9 0
      packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert-icon.directive.ts
  29. 21 0
      packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert-title.directive.ts
  30. 40 0
      packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert.directive.ts
  31. 11 0
      packages/admin-ui/src/lib/ui/ui-button-helm/src/index.ts
  32. 66 0
      packages/admin-ui/src/lib/ui/ui-button-helm/src/lib/hlm-button.directive.ts
  33. 22 0
      packages/admin-ui/src/lib/ui/ui-button-helm/src/lib/hlm-button.token.ts
  34. 12 0
      packages/admin-ui/src/lib/ui/ui-checkbox-helm/src/index.ts
  35. 136 0
      packages/admin-ui/src/lib/ui/ui-checkbox-helm/src/lib/hlm-checkbox.component.ts
  36. 14 0
      packages/admin-ui/src/lib/ui/ui-formfield-helm/src/index.ts
  37. 136 0
      packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/form-field.spec.ts
  38. 11 0
      packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/hlm-error.directive.ts
  39. 40 0
      packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/hlm-form-field.component.ts
  40. 11 0
      packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/hlm-hint.directive.ts
  41. 12 0
      packages/admin-ui/src/lib/ui/ui-input-helm/src/index.ts
  42. 22 0
      packages/admin-ui/src/lib/ui/ui-input-helm/src/lib/hlm-input-error.directive.ts
  43. 96 0
      packages/admin-ui/src/lib/ui/ui-input-helm/src/lib/hlm-input.directive.ts
  44. 10 0
      packages/admin-ui/src/lib/ui/ui-label-helm/src/index.ts
  45. 73 0
      packages/admin-ui/src/lib/ui/ui-label-helm/src/lib/hlm-label.directive.ts
  46. 17 0
      packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/index.ts
  47. 23 0
      packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/lib/hlm-radio-group.component.ts
  48. 24 0
      packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/lib/hlm-radio-indicator.component.ts
  49. 20 0
      packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/lib/hlm-radio.directive.ts
  50. 10 0
      packages/admin-ui/src/lib/ui/ui-spinner-helm/src/index.ts
  51. 51 0
      packages/admin-ui/src/lib/ui/ui-spinner-helm/src/lib/hlm-spinner.component.ts
  52. 14 0
      packages/admin-ui/src/lib/ui/ui-switch-helm/src/index.ts
  53. 32 0
      packages/admin-ui/src/lib/ui/ui-switch-helm/src/lib/hlm-switch-ng-model.component.ignore.spec.ts
  54. 21 0
      packages/admin-ui/src/lib/ui/ui-switch-helm/src/lib/hlm-switch-thumb.directive.ts
  55. 98 0
      packages/admin-ui/src/lib/ui/ui-switch-helm/src/lib/hlm-switch.component.ts
  56. 27 0
      packages/admin-ui/src/lib/ui/ui-tabs-helm/src/index.ts
  57. 24 0
      packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-content.directive.ts
  58. 37 0
      packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-list.component.ts
  59. 95 0
      packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-paginated-list.component.ts
  60. 24 0
      packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-trigger.directive.ts
  61. 18 0
      packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs.component.ts
  62. 6 0
      packages/admin-ui/tsconfig.json

+ 2 - 4
packages/admin-ui/src/lib/core/src/components/alerts/alerts.component.html

@@ -1,9 +1,7 @@
 <vdr-dropdown>
-    <button class="alerts-button" vdrDropdownTrigger>
+    <button hlmBtn variant="secondary" size="icon" vdrDropdownTrigger>
         <vdr-status-badge *ngIf="hasAlerts$ | async" [type]="'warning'"></vdr-status-badge>
-        <div class="user-circle">
-            <ng-icon hlm shape="bell" size="16"></ng-icon>
-        </div>
+        <ng-icon hlm name="lucideBell" size="sm"></ng-icon>
     </button>
     <vdr-dropdown-menu vdrPosition="bottom-right">
         <ng-container *ngIf="activeAlerts$ | async as activeAlerts">

+ 2 - 0
packages/admin-ui/src/lib/core/src/components/alerts/alerts.component.ts

@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { ActiveAlert, AlertsService } from '../../providers/alerts/alerts.service';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideBell } from '@ng-icons/lucide';
 
 @Component({
     selector: 'vdr-alerts',

+ 23 - 62
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html

@@ -1,80 +1,41 @@
-<div class="app-container" [dir]="direction$ | async">
-    <div class="left-nav" [class.expanded]="mainNavExpanded$ | async">
-        <div class="branding">
+<div class="flex h-dvh overflow-hidden" [dir]="direction$ | async">
+    <div
+        class="bg-background w-[250px] flex flex-col border-border border-e"
+        [class.expanded]="mainNavExpanded$ | async"
+    >
+        <div class="h-14 flex items-center px-4 border-b border-border">
             <a [routerLink]="['/']" *ngIf="!hideVendureBranding">
-                <img src="assets/logo-top.webp" class="logo max-w-[100px]" />
+                <vdr-logo-full class="h-6 w-auto text-accent-400"></vdr-logo-full>
             </a>
-            <div class="collapse-menu">
-                <button class="" (click)="collapseNav()">
-                    <ng-icon hlm shape="window-close" size="24"></ng-icon>
-                </button>
-            </div>
         </div>
-        <div class="mx-4">
+        <div class="collapse-menu">
+            <button class="" (click)="collapseNav()">
+                <ng-icon hlm name="lucideX" size="lg"></ng-icon>
+            </button>
+        </div>
+        <div class="mx-4 mt-4 mb-2">
             <vdr-channel-switcher *vdrIfMultichannel></vdr-channel-switcher>
         </div>
-        <div class="main-nav-container">
+        <div class="overflow-y-auto grow">
             <vdr-main-nav (itemClick)="collapseNav()"></vdr-main-nav>
         </div>
-        <div class="settings-nav-container">
-            <hr />
+        <div
+            class="relative pt-4 border-t border-dashed border-border before:block before:-top-[1px] before:-translate-y-full before:left-0 before:absolute before:h-20 before:bg-gradient-to-t before:from-muted before:to-transparent before:w-full"
+        >
             <vdr-main-nav displayMode="settings" (itemClick)="collapseNav()"></vdr-main-nav>
         </div>
-        <div class="mx-2 flex center mb-1" [class.mt-2]="hideVersion && !devMode">
-            <div *ngIf="!hideVersion" class="version">v{{ version }}</div>
-            <vdr-dropdown *ngIf="devMode">
-                <button class="icon-button dev-mode-button" vdrDropdownTrigger title="DEV MODE">
-                    <ng-icon hlm shape="code" size="24"></ng-icon> DEV MODE
-                </button>
-                <vdr-dropdown-menu>
-                    <div class="px-2 py-1">
-                        <div>Version: {{ version }}</div>
-                        <div>View UI extension points: <kbd>CTRL + U</kbd></div>
-                    </div>
-                </vdr-dropdown-menu>
-            </vdr-dropdown>
+
+        <div *ngIf="!hideVersion" class="text-center text-sm text-medium text-muted-foreground pb-2">
+            v{{ version }}
         </div>
     </div>
 
     <div class="surface">
         <div class="content-container">
-            <div class="top-bar">
-                <div class="expand-menu mr-1">
-                    <button class="" (click)="expandNav()">
-                        <svg
-                            xmlns="http://www.w3.org/2000/svg"
-                            fill="none"
-                            viewBox="0 0 24 24"
-                            stroke-width="1.5"
-                            stroke="currentColor"
-                            class="bars"
-                        >
-                            <path
-                                stroke-linecap="round"
-                                stroke-linejoin="round"
-                                d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
-                            />
-                        </svg>
-                    </button>
-                </div>
-                <div>
-                    <vdr-breadcrumb></vdr-breadcrumb>
-                </div>
-                <div class="universal-search flex-spacer"></div>
-                <div class="mx-1">
-                    <vdr-alerts></vdr-alerts>
-                </div>
-                <div>
-                    <vdr-user-menu
-                        [userName]="userName$ | async"
-                        [uiLanguageAndLocale]="uiLanguageAndLocale$ | async"
-                        [availableLanguages]="availableLanguages"
-                        (selectUiLanguage)="selectUiLanguage()"
-                        (logOut)="logOut()"
-                    />
-                </div>
+            <vdr-top-bar />
+            <div class="w-full max-w-screen-xl mx-auto">
+                <router-outlet></router-outlet>
             </div>
-            <div class="content-area"><router-outlet></router-outlet></div>
         </div>
     </div>
 </div>

+ 2 - 0
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.ts

@@ -68,6 +68,8 @@ export class AppShellComponent implements OnInit {
         this.mainNavExpanded$ = this.dataService.client
             .uiState()
             .stream$.pipe(map(({ uiState }) => uiState.mainNavExpanded));
+
+        this.mainNavExpanded$.subscribe(val => console.log(val));
     }
 
     selectUiLanguage() {

+ 9 - 9
packages/admin-ui/src/lib/core/src/components/base-nav/base-nav.component.ts

@@ -97,28 +97,28 @@ export class BaseNavComponent implements OnInit, OnDestroy {
                         requiresPermission: allow(Permission.ReadCatalog, Permission.ReadProduct),
                         id: 'products',
                         label: _('nav.products'),
-                        icon: 'library',
+                        icon: 'lucideTag',
                         routerLink: ['/catalog', 'products'],
                     },
                     {
                         requiresPermission: allow(Permission.ReadCatalog, Permission.ReadFacet),
                         id: 'facets',
                         label: _('nav.facets'),
-                        icon: 'tag',
+                        icon: 'lucideFilter',
                         routerLink: ['/catalog', 'facets'],
                     },
                     {
                         requiresPermission: allow(Permission.ReadCatalog, Permission.ReadCollection),
                         id: 'collections',
                         label: _('nav.collections'),
-                        icon: 'folder-open',
+                        icon: 'lucideCombine',
                         routerLink: ['/catalog', 'collections'],
                     },
                     {
                         requiresPermission: allow(Permission.ReadCatalog, Permission.ReadAsset),
                         id: 'assets',
                         label: _('nav.assets'),
-                        icon: 'image-gallery',
+                        icon: 'lucideImages',
                         routerLink: ['/catalog', 'assets'],
                     },
                 ],
@@ -133,7 +133,7 @@ export class BaseNavComponent implements OnInit, OnDestroy {
                         id: 'orders',
                         label: _('nav.orders'),
                         routerLink: ['/orders'],
-                        icon: 'shopping-cart',
+                        icon: 'lucideShoppingCart',
                     },
                 ],
             },
@@ -147,14 +147,14 @@ export class BaseNavComponent implements OnInit, OnDestroy {
                         id: 'customers',
                         label: _('nav.customers'),
                         routerLink: ['/customer', 'customers'],
-                        icon: 'user',
+                        icon: 'lucideUser',
                     },
                     {
                         requiresPermission: allow(Permission.ReadCustomerGroup),
                         id: 'customer-groups',
                         label: _('nav.customer-groups'),
                         routerLink: ['/customer', 'groups'],
-                        icon: 'users',
+                        icon: 'lucideUsers',
                     },
                 ],
             },
@@ -168,7 +168,7 @@ export class BaseNavComponent implements OnInit, OnDestroy {
                         id: 'promotions',
                         label: _('nav.promotions'),
                         routerLink: ['/marketing', 'promotions'],
-                        icon: 'asterisk',
+                        icon: 'lucideTicketPercent',
                     },
                 ],
             },
@@ -299,7 +299,7 @@ export class BaseNavComponent implements OnInit, OnDestroy {
                                     ({
                                         type: jobs.length === 0 ? 'none' : 'info',
                                         propagateToSection: jobs.length > 0,
-                                    } as NavMenuBadge),
+                                    }) as NavMenuBadge,
                             ),
                         ),
                     },

+ 14 - 15
packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html

@@ -1,18 +1,17 @@
-<nav role="navigation">
-    <ul class="flex items-center justify-start text-xs">
-        <li
-            *ngFor="let breadcrumb of breadcrumbs$ | async; let isLast = last"
-            [title]="breadcrumb.label | translate"
-        >
-            <a [routerLink]="breadcrumb.link" *ngIf="!isLast">{{ breadcrumb.label | translate }}</a>
-            <ng-container *ngIf="!isLast">
-                <ng-icon hlm shape="caret right" class="h-2 w-2 text-gray-300 mx-1"></ng-icon>
-            </ng-container>
-            <ng-container *ngIf="isLast">
-                <span class="text-gray-600 font-semibold">{{ breadcrumb.label | translate }}</span>
-            </ng-container>
-        </li>
-    </ul>
+<nav role="navigation" hlmBreadcrumb>
+    <ol hlmBreadcrumbList>
+        <ng-container *ngFor="let breadcrumb of breadcrumbs$ | async; let isLast = last">
+            <li hlmBreadcrumbItem [title]="breadcrumb.label | translate">
+                <a hlmBreadcrumbLink [link]="breadcrumb.link" *ngIf="!isLast">
+                    {{ breadcrumb.label | translate }}
+                </a>
+                <span *ngIf="isLast" hlmBreadcrumbPage>
+                    {{ breadcrumb.label | translate }}
+                </span>
+            </li>
+            <li hlmBreadcrumbSeparator *ngIf="!isLast"></li>
+        </ng-container>
+    </ol>
     <ul class="breadcrumbs mobile" *ngIf="parentBreadcrumb$ | async as parentBreadcrumb">
         <li>
             <ng-icon hlm shape="caret left" class="color-weight-400 mr-1"></ng-icon>

+ 16 - 6
packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.html

@@ -1,11 +1,21 @@
 <ng-container>
     <vdr-dropdown>
-        <button class="active-channel m-auto" vdrDropdownTrigger>
-            <vdr-channel-badge [channelCode]="activeChannelCode$ | async"></vdr-channel-badge>
-            <span class="channel-label">{{
-                activeChannelCode$ | async | channelCodeToLabel | translate
-            }}</span>
-            <span class="trigger"><ng-icon hlm name="lucideEllipsisVertical"></ng-icon></span>
+        <button
+            class="w-full justify-between text-muted-foreground"
+            hlmBtn
+            variant="outline"
+            vdrDropdownTrigger
+            size="sm"
+        >
+            <span>
+                <vdr-channel-badge
+                    class="mr-2"
+                    size="sm"
+                    [channelCode]="activeChannelCode$ | async"
+                ></vdr-channel-badge>
+                {{ activeChannelCode$ | async | channelCodeToLabel | translate }}
+            </span>
+            <ng-icon class="ml-1" hlm name="lucideEllipsisVertical" size="sm"></ng-icon>
         </button>
         <vdr-dropdown-menu vdrPosition="bottom-right">
             <input

+ 36 - 29
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html

@@ -1,7 +1,7 @@
 <nav class="main-nav pr-6 pl-4">
     <ng-container *ngFor="let section of mainMenuConfig$ | async">
         <div
-            class="overflow-hidden transition-all pb-2 nav-group first:pt-4"
+            class="overflow-hidden transition-all pb-2 nav-group"
             [attr.data-section-id]="section.id"
             [class.collapsible]="section.collapsible"
             [class.collapsed]="section.collapsible && !expandedSections.includes(section.id)"
@@ -16,16 +16,19 @@
                 [leftPx]="8"
                 display="block"
             >
-                <div class="section-header">
+                <div class="mb-2 mt-1 flex items-center justify-between">
                     <ng-container *ngIf="navBuilderService.sectionBadges[section.id] | async as sectionBadge">
                         <vdr-status-badge
                             *ngIf="sectionBadge !== 'none'"
                             [type]="sectionBadge"
                         ></vdr-status-badge>
                     </ng-container>
-                    <label class="nav-group-header mx-4" [for]="section.id">{{
-                        section.label | translate
-                    }}</label>
+                    <label
+                        class="font-medium text-xs tracking-wide uppercase text-muted-foreground mx-2"
+                        [for]="section.id"
+                    >
+                        {{ section.label | translate }}
+                    </label>
                     <button
                         *ngIf="section.collapsible"
                         class="rounded-full bg-gray-200 h-5 w-5 flex items-center justify-center"
@@ -33,35 +36,39 @@
                     >
                         <ng-icon
                             hlm
-                            [attr.shape]="expandedSections.includes(section.id) ? 'caret up' : 'caret down'"
-                            class="h-3 w-3"
+                            name="lucideChevronUp"
+                            size="sm"
+                            class="transition-transform"
+                            [ngClass]="{ 'transform rotate-180': expandedSections.includes(section.id) }"
                             [title]="'common.expand-entries' | translate"
                         ></ng-icon>
                     </button>
                 </div>
-                <div class="nav-list" [ngStyle]="getStyleForSection(section)">
+                <div class="flex flex-col gap-1" [ngStyle]="getStyleForSection(section)">
                     <ng-container *ngFor="let item of section.items">
-                        <div *ngIf="shouldDisplayLink(item)" class="nav-link" routerLinkActive="active">
-                            <a
-                                [attr.data-item-id]="section.id"
-                                [routerLink]="getRouterLink(item)"
-                                (click)="onItemClick(item, $event)"
-                            >
-                                <ng-container *ngIf="item.statusBadge | async as itemBadge">
-                                    <vdr-status-badge
-                                        *ngIf="itemBadge.type !== 'none'"
-                                        [type]="itemBadge.type"
-                                    ></vdr-status-badge>
-                                </ng-container>
-                                <ng-icon
-                                    hlm
-                                    [attr.shape]="item.icon || 'block'"
-                                    class="h-4 w-4 nav-link-icon"
-                                    [title]="item.label | translate"
-                                ></ng-icon>
-                                <span class="">{{ item.label | translate }}</span>
-                            </a>
-                        </div>
+                        <a
+                            *ngIf="shouldDisplayLink(item)"
+                            [attr.data-item-id]="section.id"
+                            [routerLink]="getRouterLink(item)"
+                            (click)="onItemClick(item, $event)"
+                            class="rounded-md flex px-2 items-center gap-2 py-1 _hover:border-border"
+                            routerLinkActive="bg-white text-foreground border border-border inset text-foreground"
+                        >
+                            <ng-container *ngIf="item.statusBadge | async as itemBadge">
+                                <vdr-status-badge
+                                    *ngIf="itemBadge.type !== 'none'"
+                                    [type]="itemBadge.type"
+                                ></vdr-status-badge>
+                            </ng-container>
+                            <ng-icon
+                                hlm
+                                [name]="item.icon || 'block'"
+                                class="nav-link-icon"
+                                size="sm"
+                                [title]="item.label | translate"
+                            ></ng-icon>
+                            <span class="text-sm">{{ item.label | translate }}</span>
+                        </a>
                     </ng-container>
                 </div>
             </vdr-ui-extension-point>

+ 1 - 0
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts

@@ -1,6 +1,7 @@
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
+
 import { NavMenuItem, NavMenuSection } from '../../providers/nav-builder/nav-builder-types';
 import { BaseNavComponent } from '../base-nav/base-nav.component';
 

+ 3 - 7
packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.html

@@ -1,9 +1,5 @@
-<button *ngIf="activeTheme$ | async as activeTheme" class="theme-toggle">
+<button *ngIf="activeTheme$ | async as activeTheme">
+    <ng-icon *ngIf="activeTheme === 'default'" hlm name="lucideSun" hlmMenuIcon></ng-icon>
+    <ng-icon *ngIf="activeTheme === 'dark'" hlm name="lucideMoon" hlmMenuIcon></ng-icon>
     <span>{{ 'common.theme' | translate }}</span>
-    <div class="theme-icon default" [class.active]="activeTheme === 'default'">
-        <ng-icon hlm shape="sun" class="is-solid"></ng-icon>
-    </div>
-    <div class="theme-icon dark" [class.active]="activeTheme === 'dark'">
-        <ng-icon hlm shape="moon" class="is-solid"></ng-icon>
-    </div>
 </button>

+ 21 - 19
packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.html

@@ -1,29 +1,31 @@
-<vdr-dropdown>
-    <button class="trigger user-menu-btn" vdrDropdownTrigger>
-        <div class="user-circle">
-            <ng-icon hlm shape="user" size="16"></ng-icon>
-        </div>
-        <span class="user-name">{{ userName }}</span>
-        <ng-icon hlm class="md:hidden" shape="caret down"></ng-icon>
-    </button>
-    <vdr-dropdown-menu vdrPosition="bottom-right">
-        <a [routerLink]="['/settings', 'profile']" vdrDropdownItem tabindex="0">
-            <ng-icon hlm name="lucideUser"></ng-icon> {{ 'settings.profile' | translate }}
+<button hlmBtn variant="secondary" [brnMenuTriggerFor]="userMenu">
+    <div class="h-7 w-7 bg-white border border-border rounded flex justify-center items-center mr-2">
+        <ng-icon hlm name="lucideUser" size="sm"></ng-icon>
+    </div>
+    <span class="user-name">{{ userName }}</span>
+</button>
+<ng-template #userMenu>
+    <hlm-menu>
+        <a [routerLink]="['/settings', 'profile']" hlmMenuItem tabindex="0">
+            <ng-icon hlm name="lucideUserCog" hlmMenuIcon></ng-icon>
+            {{ 'settings.profile' | translate }}
         </a>
         <ng-container *ngIf="1 < availableLanguages.length">
             <button
                 type="button"
-                vdrDropdownItem
+                hlmMenuItem
                 (click)="selectUiLanguage.emit()"
                 [title]="'common.select-display-language' | translate"
             >
-                <ng-icon hlm shape="language"></ng-icon> {{ uiLanguageAndLocale?.[0] | localeLanguageName }}
+                <ng-icon hlm name="lucideLanguages" hlmMenuIcon></ng-icon>
+                {{ 'common.language' | translate }}
             </button>
         </ng-container>
-        <vdr-theme-switcher vdrDropdownItem tabindex="0"></vdr-theme-switcher>
-        <div class="dropdown-divider"></div>
-        <button type="button" vdrDropdownItem (click)="logOut.emit()">
-            <ng-icon hlm shape="logout"></ng-icon> {{ 'common.log-out' | translate }}
+        <vdr-theme-switcher hlmMenuItem tabindex="0"></vdr-theme-switcher>
+        <hlm-menu-separator />
+        <button hlmMenuItem (click)="logOut.emit()">
+            <ng-icon hlm name="lucideLogOut" hlmMenuIcon></ng-icon>
+            {{ 'common.log-out' | translate }}
         </button>
-    </vdr-dropdown-menu>
-</vdr-dropdown>
+    </hlm-menu>
+</ng-template>

+ 2 - 0
packages/admin-ui/src/lib/core/src/core.module.ts

@@ -30,6 +30,7 @@ import { LocalStorageService } from './providers/local-storage/local-storage.ser
 import { Permission } from './public_api';
 import { registerDefaultFormInputs } from './shared/dynamic-form-inputs/default-form-inputs';
 import { SharedModule } from './shared/shared.module';
+import TopBarComponent from './shared/components/top-bar/top-bar.component';
 
 @NgModule({
     imports: [
@@ -62,6 +63,7 @@ import { SharedModule } from './shared/shared.module';
         ChannelSwitcherComponent,
         ThemeSwitcherComponent,
         AlertsComponent,
+        TopBarComponent,
     ],
 })
 export class CoreModule {

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.html

@@ -1,5 +1,6 @@
 <ng-icon
     hlm
-    shape="layers"
+    [size]="size"
+    name="lucideLayers"
     [style.color]="isDefaultChannel ? '#aaa' : (channelCode | stringToColor)"
 ></ng-icon>

+ 0 - 11
packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.scss

@@ -1,11 +0,0 @@
-@import "variables";
-
-:host {
-    display: inline-block;
-}
-
-clr-icon {
-    @media screen and (max-width: $breakpoint-medium) {
-        margin-inline-end: 0;
-    }
-}

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.ts

@@ -4,11 +4,12 @@ import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 @Component({
     selector: 'vdr-channel-badge',
     templateUrl: './channel-badge.component.html',
-    styleUrls: ['./channel-badge.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class ChannelBadgeComponent {
     @Input() channelCode: string;
+    @Input() size: string;
+
     get isDefaultChannel(): boolean {
         return this.channelCode === DEFAULT_CHANNEL_CODE;
     }

Fișier diff suprimat deoarece este prea mare
+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/logo-full/logo-full.component.svg


+ 9 - 0
packages/admin-ui/src/lib/core/src/shared/components/logo-full/logo-full.component.ts

@@ -0,0 +1,9 @@
+import { Component, Input } from '@angular/core';
+
+@Component({
+    selector: 'vdr-logo-full',
+    templateUrl: './logo-full.component.svg',
+})
+export class LogoFullComponent {
+    @Input() class: string = '';
+}

+ 41 - 0
packages/admin-ui/src/lib/core/src/shared/components/top-bar/top-bar.component.html

@@ -0,0 +1,41 @@
+<div class="flex items-center justify-between px-2 h-14 border-b border-border bg-background">
+    <div class="flex items-center justify-start gap-2">
+        <button variant="ghost" class="text-muted-foreground" hlmBtn size="icon" (click)="collapseNav()">
+            <ng-icon size="sm" hlm name="lucidePanelLeftClose"></ng-icon>
+        </button>
+        <vdr-breadcrumb></vdr-breadcrumb>
+    </div>
+
+    <div class="flex items-center justify-end gap-2">
+        <button
+            *ngIf="devMode"
+            hlmBtn
+            size="xs"
+            variant="destructive"
+            class="shrink-0"
+            [brnMenuTriggerFor]="devMode"
+            title="DEV MODE"
+        >
+            <ng-icon hlm name="lucideCode" size="sm" class="mr-1"></ng-icon>
+            DEV MODE
+        </button>
+
+        <ng-template #devMode>
+            <hlm-menu>
+                <hlm-menu-label>Dev Mode</hlm-menu-label>
+                <button hlmMenuItem>
+                    UI extensions
+                    <hlm-menu-shortcut>Ctrl+U</hlm-menu-shortcut>
+                </button>
+            </hlm-menu>
+        </ng-template>
+        <vdr-alerts></vdr-alerts>
+        <vdr-user-menu
+            [userName]="userName$ | async"
+            [uiLanguageAndLocale]="uiLanguageAndLocale$ | async"
+            [availableLanguages]="availableLanguages"
+            (selectUiLanguage)="selectUiLanguage()"
+            (logOut)="logOut()"
+        />
+    </div>
+</div>

+ 114 - 0
packages/admin-ui/src/lib/core/src/shared/components/top-bar/top-bar.component.ts

@@ -0,0 +1,114 @@
+import { Component, isDevMode } from '@angular/core';
+import {
+    AuthService,
+    BreadcrumbService,
+    DataService,
+    getAppConfig,
+    I18nService,
+    LanguageCode,
+    LocalizationDirectionType,
+    LocalizationLanguageCodeType,
+    LocalizationService,
+    LocalStorageService,
+    ModalService,
+    UiLanguageSwitcherDialogComponent,
+} from '@vendure/admin-ui/core';
+import { EMPTY, Observable } from 'rxjs';
+import { Router } from '@angular/router';
+import { map, switchMap, take } from 'rxjs/operators';
+
+@Component({
+    selector: 'vdr-top-bar',
+    templateUrl: './top-bar.component.html',
+})
+export default class TopBarComponent {
+    userName$: Observable<string>;
+    uiLanguageAndLocale$: LocalizationLanguageCodeType;
+    direction$: LocalizationDirectionType;
+    availableLanguages: LanguageCode[] = [];
+    availableLocales: string[] = [];
+    pageTitle$: Observable<string>;
+    mainNavExpanded$: Observable<boolean>;
+    devMode = isDevMode();
+
+    constructor(
+        private authService: AuthService,
+        private dataService: DataService,
+        private router: Router,
+        private i18nService: I18nService,
+        private modalService: ModalService,
+        private localStorageService: LocalStorageService,
+        private breadcrumbService: BreadcrumbService,
+        private localizationService: LocalizationService,
+    ) {}
+
+    ngOnInit() {
+        this.direction$ = this.localizationService.direction$;
+
+        this.uiLanguageAndLocale$ = this.localizationService.uiLanguageAndLocale$;
+
+        this.userName$ = this.dataService.client
+            .userStatus()
+            .single$.pipe(map(data => data.userStatus.username));
+
+        this.availableLanguages = this.i18nService.availableLanguages;
+
+        this.availableLocales = this.i18nService.availableLocales;
+
+        this.pageTitle$ = this.breadcrumbService.breadcrumbs$.pipe(
+            map(breadcrumbs => breadcrumbs[breadcrumbs.length - 1].label),
+        );
+
+        this.mainNavExpanded$ = this.dataService.client
+            .uiState()
+            .stream$.pipe(map(({ uiState }) => uiState.mainNavExpanded));
+    }
+
+    selectUiLanguage() {
+        this.uiLanguageAndLocale$
+            .pipe(
+                take(1),
+                switchMap(([currentLanguage, currentLocale]) => {
+                    return this.modalService.fromComponent(UiLanguageSwitcherDialogComponent, {
+                        closable: true,
+                        size: 'lg',
+                        locals: {
+                            availableLocales: this.availableLocales,
+                            availableLanguages: this.availableLanguages,
+                            currentLanguage: currentLanguage,
+                            currentLocale: currentLocale,
+                        },
+                    });
+                }),
+                switchMap(result =>
+                    result ? this.dataService.client.setUiLanguage(result[0], result[1]) : EMPTY,
+                ),
+            )
+            .subscribe(result => {
+                if (result.setUiLanguage) {
+                    this.i18nService.setLanguage(result.setUiLanguage);
+                    this.localStorageService.set('uiLanguageCode', result.setUiLanguage);
+                    this.localStorageService.set('uiLocale', result.setUiLocale ?? undefined);
+                }
+            });
+    }
+
+    expandNav() {
+        this.dataService.client.setMainNavExpanded(true).subscribe();
+    }
+
+    collapseNav() {
+        this.dataService.client.setMainNavExpanded(false).subscribe();
+    }
+
+    logOut() {
+        this.authService.logOut().subscribe(() => {
+            const { loginUrl } = getAppConfig();
+            if (loginUrl) {
+                window.location.href = loginUrl;
+            } else {
+                this.router.navigate(['/login']);
+            }
+        });
+    }
+}

+ 63 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -184,6 +184,38 @@ import { AddFilterPresetButtonComponent } from './components/data-table-filter-p
 import { RenameFilterPresetDialogComponent } from './components/data-table-filter-presets/rename-filter-preset-dialog.component';
 import { ActionBarDropdownMenuComponent } from './components/action-bar-dropdown-menu/action-bar-dropdown-menu.component';
 import { DuplicateEntityDialogComponent } from './components/duplicate-entity-dialog/duplicate-entity-dialog.component';
+import { HlmButtonModule } from '@spartan-ng/ui-button-helm';
+import { LogoFullComponent } from './components/logo-full/logo-full.component';
+import { NgIcon, NgIconComponent, NgIconsModule, provideIcons } from '@ng-icons/core';
+import {
+    lucideBell,
+    lucideChevronDown,
+    lucideChevronUp,
+    lucideCode,
+    lucideCombine,
+    lucideEllipsisVertical,
+    lucideFilter,
+    lucideGripHorizontal,
+    lucideImages,
+    lucideLanguages,
+    lucideLayers,
+    lucideLogOut,
+    lucideMoon,
+    lucidePanelLeftClose,
+    lucidePanelLeftOpen,
+    lucidePanelRightOpen,
+    lucideShoppingCart,
+    lucideSun,
+    lucideTag,
+    lucideTicketPercent,
+    lucideUser,
+    lucideUserCog,
+    lucideUsers,
+    lucideX,
+} from '@ng-icons/lucide';
+import { HlmMenuModule } from '@spartan-ng/ui-menu-helm';
+import { BrnMenuModule } from '@spartan-ng/brain/menu';
+import { HlmBreadCrumbModule } from '@spartan-ng/ui-breadcrumb-helm';
 
 const IMPORTS = [
     CommonModule,
@@ -206,6 +238,11 @@ const IMPORTS = [
     HlmTabsModule,
     HlmRadioGroupModule,
     BrnRadioGroupModule,
+    HlmButtonModule,
+    NgIconComponent,
+    HlmMenuModule,
+    BrnMenuModule,
+    HlmBreadCrumbModule,
 ];
 
 const DECLARATIONS = [
@@ -341,6 +378,7 @@ const DECLARATIONS = [
     AddFilterPresetButtonComponent,
     RenameFilterPresetDialogComponent,
     DuplicateEntityDialogComponent,
+    LogoFullComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [
@@ -382,6 +420,31 @@ const DYNAMIC_FORM_INPUTS = [
         // See https://github.com/angular/angular/issues/14324#issuecomment-305650763
         ModalService,
         CanDeactivateDetailGuard,
+        provideIcons({
+            lucideChevronDown,
+            lucideChevronUp,
+            lucideGripHorizontal,
+            lucideX,
+            lucideUser,
+            lucideEllipsisVertical,
+            lucideBell,
+            lucideCombine,
+            lucideTag,
+            lucideFilter,
+            lucideImages,
+            lucideShoppingCart,
+            lucideUsers,
+            lucideTicketPercent,
+            lucideLayers,
+            lucideCode,
+            lucidePanelLeftClose,
+            lucidePanelLeftOpen,
+            lucideLogOut,
+            lucideUserCog,
+            lucideLanguages,
+            lucideMoon,
+            lucideSun,
+        }),
     ],
     schemas: [CUSTOM_ELEMENTS_SCHEMA],
 })

+ 1 - 1
packages/admin-ui/src/lib/dashboard/src/widgets/order-chart-widget/order-chart-widget.component.html

@@ -23,6 +23,6 @@
     </button>
     <div class="flex-spacer"></div>
     <button class="button-small" (click)="refresh()">
-        <ng-icon hlm shape="refresh"></ng-icon>
+        <ng-icon hlm name="lucideRefresh"></ng-icon>
     </button>
 </div>

+ 39 - 21
packages/admin-ui/src/lib/login/src/components/login/login.component.html

@@ -1,40 +1,56 @@
 <div class="grid grid-cols-2 h-dvh w-full bg-neutral-100" [dir]="direction$ | async">
     <!-- Form Area -->
     <div class="h-full flex flex-col items-center justify-center">
-        <div class="flex p-10 flex-col items-stretch justify-center max-w-[400px]">
-            <p class="text-2xl text-slate-800 font-bold mb-4">
+        <div class="flex p-10 flex-col items-stretch justify-center w-full max-w-[400px]">
+            <vdr-logo-full
+                class="w-auto text-accent-400 h-8 mb-6"
+                *ngIf="!hideVendureBranding"
+            ></vdr-logo-full>
+            <p class="text-xl font-semibold mb-4">
                 {{ 'common.login-title' | translate: { brand: hideVendureBranding ? brand : 'Vendure' } }}
             </p>
             <form class="login-form">
-                <div class="flex flex-col gap-2">
+                <div hlmAlert variant="destructive" *ngIf="errorMessage" class="mb-4">
+                    <span hlmAlertDesc>
+                        {{ errorMessage }}
+                    </span>
+                </div>
+                <div class="space-y-4">
                     <input
                         hlmInput
+                        class="w-full"
                         name="username"
                         id="login_username"
                         [(ngModel)]="username"
                         [placeholder]="'common.username' | translate"
                     />
-                    <input
-                        hlmInput
-                        name="password"
-                        type="password"
-                        id="login_password"
-                        [(ngModel)]="password"
-                        [placeholder]="'common.password' | translate"
-                    />
-                    <div hlmAlert variant="destructive" [class.visible]="errorMessage">
-                        <span hlmAlertDesc>
-                            {{ errorMessage }}
-                        </span>
+                    <div class="flex flex-col gap-1 items-end">
+                        <input
+                            hlmInput
+                            class="w-full"
+                            name="password"
+                            type="password"
+                            id="login_password"
+                            [(ngModel)]="password"
+                            [placeholder]="'common.password' | translate"
+                        />
+                        <span class="text-accent-400 underline font-medium text-sm">Forgot password?</span>
                     </div>
-                    <label hlmLabel class="flex items-center">
-                        <hlm-checkbox id="rememberme" name="rememberme" [(ngModel)]="rememberMe" />
+                    <label hlmLabel class="flex items-center text-muted-foreground">
+                        <hlm-checkbox
+                            class="mr-2"
+                            id="rememberme"
+                            name="rememberme"
+                            [(ngModel)]="rememberMe"
+                        />
                         {{ 'common.remember-me' | translate }}</label
                     >
                     <div>
                         <button
+                            hlmBtn
                             type="submit"
-                            class="button primary login-button"
+                            class="w-full"
+                            variant="accent"
                             (click)="logIn()"
                             [disabled]="!username || !password"
                         >
@@ -44,14 +60,16 @@
                 </div>
             </form>
         </div>
-        <img class="absolute right-5 top-5 h-16" src="assets/logo-login.webp" *ngIf="!hideVendureBranding" />
     </div>
 
     <!-- Image Area -->
     <div class="h-full p-8">
         <div class="rounded-md overflow-hidden relative h-full">
-            <div class="absolute bottom-10 left-10">
-                <p *ngIf="imageCreator" class="text-lg font-semibold text-white">
+            <div
+                class="z-10 bg-gradient-to-b from-transparent to-black/60 absolute top-0 left-0 bottom-0 right-0"
+            ></div>
+            <div class="z-20 absolute bottom-10 left-10" *ngIf="imageCreator">
+                <p class="font-semibold text-white">
                     Photo by
                     <a [href]="imageCreatorUrl" class="underline" target="_blank">{{ imageCreator }}</a> on
                     <a [href]="imageUnsplashUrl" class="underline" target="_blank">Unsplash</a>

Fișier diff suprimat deoarece este prea mare
+ 1 - 0
packages/admin-ui/src/lib/static/assets/logo-full.svg


+ 48 - 34
packages/admin-ui/src/lib/static/styles/global/_global.scss

@@ -1,44 +1,58 @@
-html,
-body:not([cds-text]) {
-    font-size: var(--font-size-sm);
-    font-family: Inter, sans-serif !important;
-    line-height: var(--cds-global-typography-body-line-height);
-    color: var(--color-text-100);
+:root {
+    --font-sans: '';
+    --background: 0 0% 98%;
+    --foreground: 240 10% 3.9%;
+    --card: 0 0% 100%;
+    --card-foreground: 240 10% 3.9%;
+    --popover: 0 0% 100%;
+    --popover-foreground: 240 10% 3.9%;
+    --primary: 240 5.9% 10%;
+    --primary-foreground: 0 0% 98%;
+    --secondary: 240 4.8% 95.9%;
+    --secondary-foreground: 240 5.9% 10%;
+    --muted: 240 4.8% 95.9%;
+    --muted-foreground: 240 3.8% 46.1%;
+    --accent: 240 4.8% 95.9%;
+    --accent-foreground: 240 5.9% 10%;
+    --destructive: 0 84.2% 60.2%;
+    --destructive-foreground: 0 0% 98%;
+    --border: 240 5.9% 90%;
+    --input: 240 5.9% 90%;
+    --ring: 240 5.9% 10%;
+    --radius: 0.5rem;
+    color-scheme: light;
 }
 
-body p:not([cds-text]) {
-    font-family: Inter, sans-serif !important;
+.dark {
+    --background: 240 10% 3.9%;
+    --foreground: 0 0% 98%;
+    --card: 240 10% 3.9%;
+    --card-foreground: 0 0% 98%;
+    --popover: 240 10% 3.9%;
+    --popover-foreground: 0 0% 98%;
+    --primary: 0 0% 98%;
+    --primary-foreground: 240 5.9% 10%;
+    --secondary: 240 3.7% 15.9%;
+    --secondary-foreground: 0 0% 98%;
+    --muted: 240 3.7% 15.9%;
+    --muted-foreground: 240 5% 64.9%;
+    --accent: 240 3.7% 15.9%;
+    --accent-foreground: 0 0% 98%;
+    --destructive: 0 62.8% 30.6%;
+    --destructive-foreground: 0 0% 98%;
+    --border: 240 3.7% 15.9%;
+    --input: 240 3.7% 15.9%;
+    --ring: 240 4.9% 83.9%;
+    color-scheme: dark;
 }
 
-.page-block {
-    margin-inline-start: var(--surface-margin-left);
-    margin-inline-end: var(--space-unit);
-    max-width: var(--layout-content-max-width);
+@layer base {
+    * {
+        @apply border-border;
+    }
 }
 
-::-webkit-scrollbar {
-    width: 10px;
-    height: 10px;
-}
 
-::-webkit-scrollbar {
-    width: 10px;
-    height: 10px;
-}
-::-webkit-scrollbar-corner {
-    background-color: var(--color-scrollbar-bg);
-}
-::-webkit-scrollbar-thumb {
-    background-color: var(--color-scrollbar-thumb);
-    border: 2px solid var(--color-scrollbar-bg);
-    border-radius: 10px;
-}
-::-webkit-scrollbar-thumb:hover {
-    background-color: var(--color-scrollbar-thumb-hover);
-}
-::-webkit-scrollbar-track {
-    background-color: var(--color-scrollbar-bg);
-}
 
 .clr-sr-only {
     @apply sr-only;

+ 1 - 1
packages/admin-ui/src/lib/static/styles/styles.scss

@@ -9,7 +9,7 @@
 // @import 'global/overrides';
 // @import 'global/utilities';
 @import 'fonts';
-// @import 'global/global';
+@import 'global/global';
 // @import 'global/clarity-bc';
 
 @import "component/prosemirror";

+ 24 - 0
packages/admin-ui/src/lib/ui/ui-alert-helm/src/index.ts

@@ -0,0 +1,24 @@
+import { NgModule } from '@angular/core';
+
+import { HlmAlertDescriptionDirective } from './lib/hlm-alert-description.directive';
+import { HlmAlertIconDirective } from './lib/hlm-alert-icon.directive';
+import { HlmAlertTitleDirective } from './lib/hlm-alert-title.directive';
+import { HlmAlertDirective } from './lib/hlm-alert.directive';
+
+export * from './lib/hlm-alert-description.directive';
+export * from './lib/hlm-alert-icon.directive';
+export * from './lib/hlm-alert-title.directive';
+export * from './lib/hlm-alert.directive';
+
+export const HlmAlertImports = [
+	HlmAlertDirective,
+	HlmAlertTitleDirective,
+	HlmAlertDescriptionDirective,
+	HlmAlertIconDirective,
+] as const;
+
+@NgModule({
+	imports: [...HlmAlertImports],
+	exports: [...HlmAlertImports],
+})
+export class HlmAlertModule {}

+ 21 - 0
packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert-description.directive.ts

@@ -0,0 +1,21 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const alertDescriptionVariants = cva('text-sm [&_p]:leading-relaxed', {
+	variants: {},
+});
+export type AlertDescriptionVariants = VariantProps<typeof alertDescriptionVariants>;
+
+@Directive({
+	selector: '[hlmAlertDesc],[hlmAlertDescription]',
+	standalone: true,
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmAlertDescriptionDirective {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected readonly _computedClass = computed(() => hlm(alertDescriptionVariants(), this.userClass()));
+}

+ 9 - 0
packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert-icon.directive.ts

@@ -0,0 +1,9 @@
+import { Directive } from '@angular/core';
+import { provideHlmIconConfig } from '@spartan-ng/ui-icon-helm';
+
+@Directive({
+	selector: '[hlmAlertIcon]',
+	standalone: true,
+	providers: [provideHlmIconConfig({ size: 'sm' })],
+})
+export class HlmAlertIconDirective {}

+ 21 - 0
packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert-title.directive.ts

@@ -0,0 +1,21 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const alertTitleVariants = cva('mb-1 font-medium leading-none tracking-tight', {
+	variants: {},
+});
+export type AlertTitleVariants = VariantProps<typeof alertTitleVariants>;
+
+@Directive({
+	selector: '[hlmAlertTitle]',
+	standalone: true,
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmAlertTitleDirective {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected readonly _computedClass = computed(() => hlm(alertTitleVariants(), this.userClass()));
+}

+ 40 - 0
packages/admin-ui/src/lib/ui/ui-alert-helm/src/lib/hlm-alert.directive.ts

@@ -0,0 +1,40 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const alertVariants = cva(
+    'relative w-full rounded-lg border border-border p-4 [&>[hlmAlertIcon]]:absolute [&>[hlmAlertIcon]]:text-foreground [&>[hlmAlertIcon]]:left-4 [&>[hlmAlertIcon]]:top-4 [&>[hlmAlertIcon]+div]:translate-y-[-3px] [&>[hlmAlertIcon]~*]:pl-7',
+    {
+        variants: {
+            variant: {
+                default: 'bg-background text-foreground',
+                destructive:
+                    'text-destructive border-destructive/50 dark:border-destructive [&>[hlmAlertIcon]]:text-destructive bg-destructive/10',
+                warning:
+                    'text-yellow-500 border-yellow-500/50 dark:border-yellow-500 [&>[hlmAlertIcon]]:text-yellow-500 bg-yellow-300/10',
+            },
+        },
+        defaultVariants: {
+            variant: 'default',
+        },
+    },
+);
+export type AlertVariants = VariantProps<typeof alertVariants>;
+
+@Directive({
+    selector: '[hlmAlert]',
+    standalone: true,
+    host: {
+        role: 'alert',
+        '[class]': '_computedClass()',
+    },
+})
+export class HlmAlertDirective {
+    public readonly userClass = input<ClassValue>('', { alias: 'class' });
+    protected readonly _computedClass = computed(() =>
+        hlm(alertVariants({ variant: this.variant() }), this.userClass()),
+    );
+
+    public readonly variant = input<AlertVariants['variant']>('default');
+}

+ 11 - 0
packages/admin-ui/src/lib/ui/ui-button-helm/src/index.ts

@@ -0,0 +1,11 @@
+import { NgModule } from '@angular/core';
+import { HlmButtonDirective } from './lib/hlm-button.directive';
+export * from './lib/hlm-button.token';
+
+export * from './lib/hlm-button.directive';
+
+@NgModule({
+	imports: [HlmButtonDirective],
+	exports: [HlmButtonDirective],
+})
+export class HlmButtonModule {}

+ 66 - 0
packages/admin-ui/src/lib/ui/ui-button-helm/src/lib/hlm-button.directive.ts

@@ -0,0 +1,66 @@
+import { Directive, computed, input, signal } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+import { injectBrnButtonConfig } from './hlm-button.token';
+
+export const buttonVariants = cva(
+    'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
+    {
+        variants: {
+            variant: {
+                default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+                accent: 'bg-accent-400 text-primary-foreground hover:bg-accent-600',
+                destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+                outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
+                secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+                ghost: 'hover:bg-accent hover:text-accent-foreground',
+                link: 'underline-offset-4 hover:underline text-primary',
+            },
+            size: {
+                default: 'h-10 py-2 px-4',
+                sm: 'h-9 px-3 rounded-md',
+                lg: 'h-11 px-8 rounded-md',
+                xs: 'h-7 px-2 rounded-md text-xs',
+                icon: 'h-10 w-10',
+            },
+        },
+        defaultVariants: {
+            variant: 'default',
+            size: 'default',
+        },
+    },
+);
+export type ButtonVariants = VariantProps<typeof buttonVariants>;
+
+@Directive({
+    selector: '[hlmBtn]',
+    standalone: true,
+    exportAs: 'hlmBtn',
+    host: {
+        '[class]': '_computedClass()',
+    },
+})
+export class HlmButtonDirective {
+    private readonly _config = injectBrnButtonConfig();
+
+    private readonly _additionalClasses = signal<ClassValue>('');
+
+    public readonly userClass = input<ClassValue>('', { alias: 'class' });
+
+    protected readonly _computedClass = computed(() =>
+        hlm(
+            buttonVariants({ variant: this.variant(), size: this.size() }),
+            this.userClass(),
+            this._additionalClasses(),
+        ),
+    );
+
+    public readonly variant = input<ButtonVariants['variant']>(this._config.variant);
+
+    public readonly size = input<ButtonVariants['size']>(this._config.size);
+
+    setClass(classes: string): void {
+        this._additionalClasses.set(classes);
+    }
+}

+ 22 - 0
packages/admin-ui/src/lib/ui/ui-button-helm/src/lib/hlm-button.token.ts

@@ -0,0 +1,22 @@
+import { InjectionToken, ValueProvider, inject } from '@angular/core';
+import type { ButtonVariants } from './hlm-button.directive';
+
+export interface BrnButtonConfig {
+	variant: ButtonVariants['variant'];
+	size: ButtonVariants['size'];
+}
+
+const defaultConfig: BrnButtonConfig = {
+	variant: 'default',
+	size: 'default',
+};
+
+const BrnButtonConfigToken = new InjectionToken<BrnButtonConfig>('BrnButtonConfig');
+
+export function provideBrnButtonConfig(config: Partial<BrnButtonConfig>): ValueProvider {
+	return { provide: BrnButtonConfigToken, useValue: { ...defaultConfig, ...config } };
+}
+
+export function injectBrnButtonConfig(): BrnButtonConfig {
+	return inject(BrnButtonConfigToken, { optional: true }) ?? defaultConfig;
+}

+ 12 - 0
packages/admin-ui/src/lib/ui/ui-checkbox-helm/src/index.ts

@@ -0,0 +1,12 @@
+import { NgModule } from '@angular/core';
+
+import { HlmCheckboxComponent } from './lib/hlm-checkbox.component';
+
+export * from './lib/hlm-checkbox.component';
+
+export const HlmCheckboxImports = [HlmCheckboxComponent] as const;
+@NgModule({
+	imports: [...HlmCheckboxImports],
+	exports: [...HlmCheckboxImports],
+})
+export class HlmCheckboxModule {}

+ 136 - 0
packages/admin-ui/src/lib/ui/ui-checkbox-helm/src/lib/hlm-checkbox.component.ts

@@ -0,0 +1,136 @@
+import {
+    Component,
+    booleanAttribute,
+    computed,
+    forwardRef,
+    input,
+    model,
+    output,
+    signal,
+} from '@angular/core';
+import { NG_VALUE_ACCESSOR } from '@angular/forms';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideCheck } from '@ng-icons/lucide';
+import { BrnCheckboxComponent } from '@spartan-ng/brain/checkbox';
+import { hlm } from '@spartan-ng/brain/core';
+import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
+import { HlmIconDirective } from '@spartan-ng/ui-icon-helm';
+import type { ClassValue } from 'clsx';
+
+export const HLM_CHECKBOX_VALUE_ACCESSOR = {
+    provide: NG_VALUE_ACCESSOR,
+    useExisting: forwardRef(() => HlmCheckboxComponent),
+    multi: true,
+};
+
+@Component({
+    selector: 'hlm-checkbox',
+    standalone: true,
+    imports: [BrnCheckboxComponent, NgIcon, HlmIconDirective],
+    template: `
+        <brn-checkbox
+            [id]="id()"
+            [name]="name()"
+            [class]="_computedClass()"
+            [checked]="checked()"
+            [disabled]="state().disabled()"
+            [required]="required()"
+            [aria-label]="ariaLabel()"
+            [aria-labelledby]="ariaLabelledby()"
+            [aria-describedby]="ariaDescribedby()"
+            (changed)="_handleChange()"
+            (touched)="_onTouched?.()"
+        >
+            <ng-icon [class]="_computedIconClass()" hlm size="sm" name="lucideCheck" />
+        </brn-checkbox>
+    `,
+    host: {
+        class: 'contents',
+        '[attr.id]': 'null',
+        '[attr.aria-label]': 'null',
+        '[attr.aria-labelledby]': 'null',
+        '[attr.aria-describedby]': 'null',
+    },
+    providers: [HLM_CHECKBOX_VALUE_ACCESSOR],
+    viewProviders: [provideIcons({ lucideCheck })],
+})
+export class HlmCheckboxComponent {
+    public readonly userClass = input<ClassValue>('', { alias: 'class' });
+
+    protected readonly _computedClass = computed(() =>
+        hlm(
+            'group inline-flex border border-muted-foreground shrink-0 cursor-pointer items-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' +
+                ' focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[state=checked]:text-background data-[state=checked]:bg-accent-400 data-[state=unchecked]:bg-background',
+            this.userClass(),
+            this.state().disabled() ? 'cursor-not-allowed opacity-50' : '',
+        ),
+    );
+
+    protected readonly _computedIconClass = computed(() =>
+        hlm(
+            'leading-none group-data-[state=unchecked]:opacity-0',
+            this.checked() === 'indeterminate' ? 'opacity-50' : '',
+        ),
+    );
+
+    /** Used to set the id on the underlying brn element. */
+    public readonly id = input<string | null>(null);
+
+    /** Used to set the aria-label attribute on the underlying brn element. */
+    public readonly ariaLabel = input<string | null>(null, { alias: 'aria-label' });
+
+    /** Used to set the aria-labelledby attribute on the underlying brn element. */
+    public readonly ariaLabelledby = input<string | null>(null, { alias: 'aria-labelledby' });
+
+    /** Used to set the aria-describedby attribute on the underlying brn element. */
+    public readonly ariaDescribedby = input<string | null>(null, { alias: 'aria-describedby' });
+
+    /** The checked state of the checkbox. */
+    public readonly checked = model<CheckboxValue>(false);
+
+    /** The name attribute of the checkbox. */
+    public readonly name = input<string | null>(null);
+
+    /** Whether the checkbox is required. */
+    public readonly required = input(false, { transform: booleanAttribute });
+
+    /** Whether the checkbox is disabled. */
+    public readonly disabled = input(false, { transform: booleanAttribute });
+
+    protected readonly state = computed(() => ({
+        disabled: signal(this.disabled()),
+    }));
+
+    public readonly changed = output<boolean>();
+
+    protected _onChange?: ChangeFn<CheckboxValue>;
+    protected _onTouched?: TouchFn;
+
+    protected _handleChange(): void {
+        if (this.state().disabled()) return;
+
+        const previousChecked = this.checked();
+        this.checked.set(previousChecked === 'indeterminate' ? true : !previousChecked);
+        this._onChange?.(!previousChecked);
+        this.changed.emit(!previousChecked);
+    }
+
+    /** CONROL VALUE ACCESSOR */
+    writeValue(value: CheckboxValue): void {
+        this.checked.set(!!value);
+    }
+
+    registerOnChange(fn: ChangeFn<CheckboxValue>): void {
+        this._onChange = fn;
+    }
+
+    registerOnTouched(fn: TouchFn): void {
+        this._onTouched = fn;
+    }
+
+    setDisabledState(isDisabled: boolean): void {
+        this.state().disabled.set(isDisabled);
+    }
+}
+
+type CheckboxValue = boolean | 'indeterminate';

+ 14 - 0
packages/admin-ui/src/lib/ui/ui-formfield-helm/src/index.ts

@@ -0,0 +1,14 @@
+import { NgModule } from '@angular/core';
+import { HlmErrorDirective } from './lib/hlm-error.directive';
+import { HlmFormFieldComponent } from './lib/hlm-form-field.component';
+import { HlmHintDirective } from './lib/hlm-hint.directive';
+
+export * from './lib/hlm-error.directive';
+export * from './lib/hlm-form-field.component';
+export * from './lib/hlm-hint.directive';
+
+@NgModule({
+	imports: [HlmFormFieldComponent, HlmErrorDirective, HlmHintDirective],
+	exports: [HlmFormFieldComponent, HlmErrorDirective, HlmHintDirective],
+})
+export class HlmFormFieldModule {}

+ 136 - 0
packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/form-field.spec.ts

@@ -0,0 +1,136 @@
+/* eslint-disable @angular-eslint/component-class-suffix */
+/* eslint-disable @angular-eslint/component-selector */
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+import { render, screen } from '@testing-library/angular';
+import userEvent from '@testing-library/user-event';
+
+import { HlmInputDirective } from '@spartan-ng/ui-input-helm';
+
+import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@spartan-ng/brain/forms';
+import { HlmErrorDirective } from './hlm-error.directive';
+import { HlmFormFieldComponent } from './hlm-form-field.component';
+import { HlmHintDirective } from './hlm-hint.directive';
+
+const DIRECTIVES = [HlmFormFieldComponent, HlmErrorDirective, HlmHintDirective, HlmInputDirective];
+
+@Component({
+	standalone: true,
+	selector: 'single-form-field-example',
+	imports: [ReactiveFormsModule, ...DIRECTIVES],
+	template: `
+		<hlm-form-field>
+			<input
+				data-testid="hlm-input"
+				aria-label="Your Name"
+				[formControl]="name"
+				class="w-80"
+				hlmInput
+				type="text"
+				placeholder="Your Name"
+			/>
+			<hlm-error data-testid="hlm-error">Your name is required</hlm-error>
+			<hlm-hint data-testid="hlm-hint">This is your public display name.</hlm-hint>
+		</hlm-form-field>
+	`,
+})
+class SingleFormFieldMock {
+	public name = new FormControl('', Validators.required);
+}
+
+@Component({
+	standalone: true,
+	selector: 'single-form-field-dirty-example',
+	imports: [ReactiveFormsModule, ...DIRECTIVES],
+	template: `
+		<hlm-form-field>
+			<input
+				data-testid="hlm-input"
+				aria-label="Your Name"
+				[formControl]="name"
+				class="w-80"
+				hlmInput
+				type="text"
+				placeholder="Your Name"
+			/>
+			<hlm-error data-testid="hlm-error">Your name is required</hlm-error>
+			<hlm-hint data-testid="hlm-hint">This is your public display name.</hlm-hint>
+		</hlm-form-field>
+	`,
+	providers: [{ provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher }],
+})
+class SingleFormFieldDirtyMock {
+	public name = new FormControl('', Validators.required);
+}
+
+describe('Hlm Form Field Component', () => {
+	const TEXT_HINT = 'This is your public display name.';
+	const TEXT_ERROR = 'Your name is required';
+
+	const setupFormField = async () => {
+		const { fixture } = await render(SingleFormFieldMock);
+		return {
+			user: userEvent.setup(),
+			fixture,
+			hint: screen.getByTestId('hlm-hint'),
+			error: () => screen.queryByTestId('hlm-error'),
+			trigger: screen.getByTestId('hlm-input'),
+		};
+	};
+
+	const setupFormFieldWithErrorStateDirty = async () => {
+		const { fixture } = await render(SingleFormFieldDirtyMock);
+		return {
+			user: userEvent.setup(),
+			fixture,
+			hint: screen.getByTestId('hlm-hint'),
+			error: () => screen.queryByTestId('hlm-error'),
+			trigger: screen.getByTestId('hlm-input'),
+		};
+	};
+
+	describe('SingleFormField', () => {
+		it('should show the hint if the errorState is false', async () => {
+			const { hint } = await setupFormField();
+
+			expect(hint.textContent).toBe(TEXT_HINT);
+		});
+
+		it('should show the error if the errorState is true', async () => {
+			const { user, error, trigger } = await setupFormField();
+
+			expect(error()).toBeNull();
+
+			await user.click(trigger);
+
+			await user.click(document.body);
+
+			expect(screen.queryByTestId('hlm-hint')).toBeNull();
+			expect(error()?.textContent?.trim()).toBe(TEXT_ERROR);
+		});
+	});
+
+	describe('SingleFormFieldDirty', () => {
+		it('should not display the error if the input does not have the dirty state due to the ErrorStateMatcher', async () => {
+			const { error, user, trigger } = await setupFormFieldWithErrorStateDirty();
+
+			await user.click(trigger);
+
+			await user.click(document.body);
+
+			expect(error()).toBeNull();
+		});
+
+		it('should display the error if the input has the dirty state due to the ErrorStateMatcher', async () => {
+			const { error, user, trigger } = await setupFormFieldWithErrorStateDirty();
+
+			await user.click(trigger);
+			await user.type(trigger, 'a');
+			await user.clear(trigger);
+
+			await user.click(document.body);
+
+			expect(error()?.textContent?.trim()).toBe(TEXT_ERROR);
+		});
+	});
+});

+ 11 - 0
packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/hlm-error.directive.ts

@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+
+@Directive({
+	standalone: true,
+	// eslint-disable-next-line @angular-eslint/directive-selector
+	selector: 'hlm-error',
+	host: {
+		class: 'block text-destructive text-sm font-medium',
+	},
+})
+export class HlmErrorDirective {}

+ 40 - 0
packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/hlm-form-field.component.ts

@@ -0,0 +1,40 @@
+import { Component, computed, contentChild, contentChildren, effect } from '@angular/core';
+import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
+import { HlmErrorDirective } from './hlm-error.directive';
+
+@Component({
+	selector: 'hlm-form-field',
+	template: `
+		<ng-content></ng-content>
+
+		@switch (hasDisplayedMessage()) {
+			@case ('error') {
+				<ng-content select="hlm-error"></ng-content>
+			}
+			@default {
+				<ng-content select="hlm-hint"></ng-content>
+			}
+		}
+	`,
+	standalone: true,
+	host: {
+		class: 'space-y-2 block',
+	},
+})
+export class HlmFormFieldComponent {
+	public readonly control = contentChild(BrnFormFieldControl);
+
+	public readonly errorChildren = contentChildren(HlmErrorDirective);
+
+	protected readonly hasDisplayedMessage = computed<'error' | 'hint'>(() =>
+		this.errorChildren() && this.errorChildren().length > 0 && this.control()?.errorState() ? 'error' : 'hint',
+	);
+
+	constructor() {
+		effect(() => {
+			if (!this.control()) {
+				throw new Error('hlm-form-field must contain a BrnFormFieldControl.');
+			}
+		});
+	}
+}

+ 11 - 0
packages/admin-ui/src/lib/ui/ui-formfield-helm/src/lib/hlm-hint.directive.ts

@@ -0,0 +1,11 @@
+import { Directive } from '@angular/core';
+
+@Directive({
+	// eslint-disable-next-line @angular-eslint/directive-selector
+	selector: 'hlm-hint',
+	standalone: true,
+	host: {
+		class: 'block text-sm text-muted-foreground',
+	},
+})
+export class HlmHintDirective {}

+ 12 - 0
packages/admin-ui/src/lib/ui/ui-input-helm/src/index.ts

@@ -0,0 +1,12 @@
+import { NgModule } from '@angular/core';
+import { HlmInputErrorDirective } from './lib/hlm-input-error.directive';
+import { HlmInputDirective } from './lib/hlm-input.directive';
+
+export * from './lib/hlm-input-error.directive';
+export * from './lib/hlm-input.directive';
+
+@NgModule({
+	imports: [HlmInputDirective, HlmInputErrorDirective],
+	exports: [HlmInputDirective, HlmInputErrorDirective],
+})
+export class HlmInputModule {}

+ 22 - 0
packages/admin-ui/src/lib/ui/ui-input-helm/src/lib/hlm-input-error.directive.ts

@@ -0,0 +1,22 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const inputErrorVariants = cva('text-destructive text-sm font-medium', {
+	variants: {},
+	defaultVariants: {},
+});
+export type InputErrorVariants = VariantProps<typeof inputErrorVariants>;
+
+@Directive({
+	selector: '[hlmInputError]',
+	standalone: true,
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmInputErrorDirective {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() => hlm(inputErrorVariants(), this.userClass()));
+}

+ 96 - 0
packages/admin-ui/src/lib/ui/ui-input-helm/src/lib/hlm-input.directive.ts

@@ -0,0 +1,96 @@
+import { Directive, type DoCheck, Injector, computed, effect, inject, input, signal } from '@angular/core';
+import { FormGroupDirective, NgControl, NgForm } from '@angular/forms';
+import { hlm } from '@spartan-ng/brain/core';
+import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
+import { ErrorStateMatcher, ErrorStateTracker } from '@spartan-ng/brain/forms';
+
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const inputVariants = cva(
+    'flex rounded-md border font-normal border-input bg-white text-base md:text-sm ring-offset-background file:border-0 file:text-foreground file:bg-white file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
+    {
+        variants: {
+            size: {
+                default: 'h-10 py-2 px-4 file:max-md:py-0',
+                sm: 'h-9 px-3 file:md:py-2 file:max-md:py-1.5',
+                lg: 'h-11 px-8 file:md:py-3 file:max-md:py-2.5',
+            },
+            error: {
+                auto: '[&.ng-invalid.ng-touched]:text-destructive [&.ng-invalid.ng-touched]:border-destructive [&.ng-invalid.ng-touched]:focus-visible:ring-destructive',
+                true: 'text-destructive border-destructive focus-visible:ring-destructive',
+            },
+        },
+        defaultVariants: {
+            size: 'default',
+            error: 'auto',
+        },
+    },
+);
+type InputVariants = VariantProps<typeof inputVariants>;
+
+@Directive({
+    selector: '[hlmInput]',
+    standalone: true,
+    host: {
+        '[class]': '_computedClass()',
+    },
+    providers: [
+        {
+            provide: BrnFormFieldControl,
+            useExisting: HlmInputDirective,
+        },
+    ],
+})
+export class HlmInputDirective implements BrnFormFieldControl, DoCheck {
+    public readonly size = input<InputVariants['size']>('default');
+
+    public readonly error = input<InputVariants['error']>('auto');
+
+    protected readonly state = computed(() => ({
+        error: signal(this.error()),
+    }));
+
+    public readonly userClass = input<ClassValue>('', { alias: 'class' });
+    protected readonly _computedClass = computed(() =>
+        hlm(inputVariants({ size: this.size(), error: this.state().error() }), this.userClass()),
+    );
+
+    private readonly _injector = inject(Injector);
+
+    public readonly ngControl: NgControl | null = this._injector.get(NgControl, null);
+
+    private readonly _errorStateTracker: ErrorStateTracker;
+
+    private readonly _defaultErrorStateMatcher = inject(ErrorStateMatcher);
+    private readonly _parentForm = inject(NgForm, { optional: true });
+    private readonly _parentFormGroup = inject(FormGroupDirective, { optional: true });
+
+    public readonly errorState = computed(() => this._errorStateTracker.errorState());
+
+    constructor() {
+        this._errorStateTracker = new ErrorStateTracker(
+            this._defaultErrorStateMatcher,
+            this.ngControl,
+            this._parentFormGroup,
+            this._parentForm,
+        );
+
+        effect(
+            () => {
+                if (this.ngControl) {
+                    this.setError(this._errorStateTracker.errorState());
+                }
+            },
+            { allowSignalWrites: true },
+        );
+    }
+
+    ngDoCheck() {
+        this._errorStateTracker.updateErrorState();
+    }
+
+    setError(error: InputVariants['error']) {
+        this.state().error.set(error);
+    }
+}

+ 10 - 0
packages/admin-ui/src/lib/ui/ui-label-helm/src/index.ts

@@ -0,0 +1,10 @@
+import { NgModule } from '@angular/core';
+import { HlmLabelDirective } from './lib/hlm-label.directive';
+
+export * from './lib/hlm-label.directive';
+
+@NgModule({
+	imports: [HlmLabelDirective],
+	exports: [HlmLabelDirective],
+})
+export class HlmLabelModule {}

+ 73 - 0
packages/admin-ui/src/lib/ui/ui-label-helm/src/lib/hlm-label.directive.ts

@@ -0,0 +1,73 @@
+import { Directive, computed, inject, input, signal } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { BrnLabelDirective } from '@spartan-ng/brain/label';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const labelVariants = cva(
+	'text-sm font-medium leading-none [&>[hlmInput]]:my-1 [&:has([hlmInput]:disabled)]:cursor-not-allowed [&:has([hlmInput]:disabled)]:opacity-70',
+	{
+		variants: {
+			variant: {
+				default: '',
+			},
+			error: {
+				auto: '[&:has([hlmInput].ng-invalid.ng-touched)]:text-destructive',
+				true: 'text-destructive',
+			},
+			disabled: {
+				auto: '[&:has([hlmInput]:disabled)]:opacity-70',
+				true: 'opacity-70',
+				false: '',
+			},
+		},
+		defaultVariants: {
+			variant: 'default',
+			error: 'auto',
+		},
+	},
+);
+export type LabelVariants = VariantProps<typeof labelVariants>;
+
+@Directive({
+	selector: '[hlmLabel]',
+	standalone: true,
+	hostDirectives: [
+		{
+			directive: BrnLabelDirective,
+			inputs: ['id'],
+		},
+	],
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmLabelDirective {
+	private readonly _brn = inject(BrnLabelDirective, { host: true });
+
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+
+	public readonly variant = input<LabelVariants['variant']>('default');
+
+	public readonly error = input<LabelVariants['error']>('auto');
+
+	protected readonly state = computed(() => ({
+		error: signal(this.error()),
+	}));
+
+	protected readonly _computedClass = computed(() =>
+		hlm(
+			labelVariants({
+				variant: this.variant(),
+				error: this.state().error(),
+				disabled: this._brn?.dataDisabled() ?? 'auto',
+			}),
+			'[&.ng-invalid.ng-touched]:text-destructive',
+			this.userClass(),
+		),
+	);
+
+	setError(error: LabelVariants['error']): void {
+		this.state().error.set(error);
+	}
+}

+ 17 - 0
packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/index.ts

@@ -0,0 +1,17 @@
+import { NgModule } from '@angular/core';
+
+import { HlmRadioGroupComponent } from './lib/hlm-radio-group.component';
+import { HlmRadioIndicatorComponent } from './lib/hlm-radio-indicator.component';
+import { HlmRadioDirective } from './lib/hlm-radio.directive';
+
+export * from './lib/hlm-radio-group.component';
+export * from './lib/hlm-radio-indicator.component';
+export * from './lib/hlm-radio.directive';
+
+export const HlmRadioGroupImports = [HlmRadioGroupComponent, HlmRadioDirective, HlmRadioIndicatorComponent];
+
+@NgModule({
+	imports: [...HlmRadioGroupImports],
+	exports: [...HlmRadioGroupImports],
+})
+export class HlmRadioGroupModule {}

+ 23 - 0
packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/lib/hlm-radio-group.component.ts

@@ -0,0 +1,23 @@
+import { Component, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { BrnRadioGroupDirective } from '@spartan-ng/brain/radio-group';
+import type { ClassValue } from 'clsx';
+
+@Component({
+	selector: 'hlm-radio-group',
+	standalone: true,
+	hostDirectives: [
+		{
+			directive: BrnRadioGroupDirective,
+			inputs: ['name', 'value', 'disabled', 'required', 'direction'],
+		},
+	],
+	host: {
+		'[class]': '_computedClass()',
+	},
+	template: '<ng-content />',
+})
+export class HlmRadioGroupComponent {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() => hlm('grid gap-2', this.userClass()));
+}

+ 24 - 0
packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/lib/hlm-radio-indicator.component.ts

@@ -0,0 +1,24 @@
+import { Component, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import type { ClassValue } from 'clsx';
+
+const btnLike =
+	'aspect-square rounded-full ring-offset-background group-[.cdk-keyboard-focused]:ring-2 group-[.cdk-keyboard-focused]:ring-ring group-[.cdk-keyboard-focused]:ring-offset-2 group-[.brn-radio-disabled]:cursor-not-allowed group-[.brn-radio-disabled]:opacity-50';
+
+@Component({
+	selector: 'hlm-radio-indicator',
+	standalone: true,
+	host: {
+		'[class]': '_computedClass()',
+	},
+	template: `
+		<div
+			class="bg-foreground absolute inset-0 hidden scale-[55%] rounded-full group-[.brn-radio-checked]:inline-block"
+		></div>
+		<div class="border-primary ${btnLike} rounded-full border"></div>
+	`,
+})
+export class HlmRadioIndicatorComponent {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() => hlm('relative inline-flex h-4 w-4', this.userClass()));
+}

+ 20 - 0
packages/admin-ui/src/lib/ui/ui-radiogroup-helm/src/lib/hlm-radio.directive.ts

@@ -0,0 +1,20 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import type { ClassValue } from 'clsx';
+
+@Directive({
+	selector: 'brn-radio[hlm],[hlmRadio]',
+	standalone: true,
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmRadioDirective {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() =>
+		hlm(
+			'group [&.brn-radio-disabled]:text-muted-foreground flex items-center space-x-2 rtl:space-x-reverse',
+			this.userClass(),
+		),
+	);
+}

+ 10 - 0
packages/admin-ui/src/lib/ui/ui-spinner-helm/src/index.ts

@@ -0,0 +1,10 @@
+import { NgModule } from '@angular/core';
+import { HlmSpinnerComponent } from './lib/hlm-spinner.component';
+
+export * from './lib/hlm-spinner.component';
+
+@NgModule({
+	imports: [HlmSpinnerComponent],
+	exports: [HlmSpinnerComponent],
+})
+export class HlmSpinnerModule {}

+ 51 - 0
packages/admin-ui/src/lib/ui/ui-spinner-helm/src/lib/hlm-spinner.component.ts

@@ -0,0 +1,51 @@
+import { Component, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const spinnerVariants = cva('inline-block', {
+	variants: {
+		variant: {
+			default: 'animate-spin [&>svg]:text-foreground/30 [&>svg]:fill-accent',
+		},
+		size: {
+			xs: 'h-4 w-4',
+			sm: 'h-6 w-6',
+			default: 'w-8 h-8 ',
+			lg: 'w-12 h-12',
+			xl: 'w-16 h-16',
+		},
+	},
+	defaultVariants: {
+		variant: 'default',
+		size: 'default',
+	},
+});
+export type SpinnerVariants = VariantProps<typeof spinnerVariants>;
+
+@Component({
+	selector: 'hlm-spinner',
+	standalone: true,
+	host: {
+		'[class]': '_computedClass()',
+		role: 'status',
+	},
+	template: `
+		<svg aria-hidden="true" class="animate-spin" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
+			<path
+				d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
+				fill="currentColor"
+			/>
+			<path
+				d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
+				fill="currentFill"
+			/>
+		</svg>
+		<span class="sr-only"><ng-content /></span>
+	`,
+})
+export class HlmSpinnerComponent {
+	public readonly size = input<SpinnerVariants['size']>('default');
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() => hlm(spinnerVariants({ size: this.size() }), this.userClass()));
+}

+ 14 - 0
packages/admin-ui/src/lib/ui/ui-switch-helm/src/index.ts

@@ -0,0 +1,14 @@
+import { NgModule } from '@angular/core';
+
+import { HlmSwitchThumbDirective } from './lib/hlm-switch-thumb.directive';
+import { HlmSwitchComponent } from './lib/hlm-switch.component';
+
+export * from './lib/hlm-switch-thumb.directive';
+export * from './lib/hlm-switch.component';
+
+export const HlmSwitchImports = [HlmSwitchComponent, HlmSwitchThumbDirective] as const;
+@NgModule({
+	imports: [...HlmSwitchImports],
+	exports: [...HlmSwitchImports],
+})
+export class HlmSwitchModule {}

+ 32 - 0
packages/admin-ui/src/lib/ui/ui-switch-helm/src/lib/hlm-switch-ng-model.component.ignore.spec.ts

@@ -0,0 +1,32 @@
+import { Component, Input } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { HlmSwitchComponent } from './hlm-switch.component';
+@Component({
+	selector: 'hlm-switch-ng-model',
+	standalone: true,
+	template: `
+		<!-- eslint-disable-next-line @angular-eslint/template/label-has-associated-control -->
+		<label class="flex items-center" hlmLabel>
+			<hlm-switch
+				[(ngModel)]="switchValue"
+				id="testSwitchForm"
+				aria-label="test switch"
+				(changed)="handleChange($event)"
+			/>
+		</label>
+
+		<p data-testid="switchValue">{{ switchValue }}</p>
+		<p data-testid="changedValue">{{ changedValueTo }}</p>
+	`,
+	imports: [HlmSwitchComponent, FormsModule],
+})
+export class SwitchFormComponent {
+	@Input()
+	public switchValue = false;
+
+	protected changedValueTo: boolean | undefined;
+
+	handleChange(value: boolean) {
+		this.changedValueTo = value;
+	}
+}

+ 21 - 0
packages/admin-ui/src/lib/ui/ui-switch-helm/src/lib/hlm-switch-thumb.directive.ts

@@ -0,0 +1,21 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import type { ClassValue } from 'clsx';
+
+@Directive({
+	selector: 'brn-switch-thumb[hlm],[hlmSwitchThumb]',
+	standalone: true,
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmSwitchThumbDirective {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+
+	protected readonly _computedClass = computed(() =>
+		hlm(
+			'block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform group-data-[state=checked]:translate-x-5 group-data-[state=unchecked]:translate-x-0',
+			this.userClass(),
+		),
+	);
+}

+ 98 - 0
packages/admin-ui/src/lib/ui/ui-switch-helm/src/lib/hlm-switch.component.ts

@@ -0,0 +1,98 @@
+import { BooleanInput } from '@angular/cdk/coercion';
+import { Component, booleanAttribute, computed, forwardRef, input, model, output } from '@angular/core';
+import { NG_VALUE_ACCESSOR } from '@angular/forms';
+import { hlm } from '@spartan-ng/brain/core';
+import { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
+import { BrnSwitchComponent, BrnSwitchThumbComponent } from '@spartan-ng/brain/switch';
+import type { ClassValue } from 'clsx';
+import { HlmSwitchThumbDirective } from './hlm-switch-thumb.directive';
+export const HLM_SWITCH_VALUE_ACCESSOR = {
+	provide: NG_VALUE_ACCESSOR,
+	useExisting: forwardRef(() => HlmSwitchComponent),
+	multi: true,
+};
+
+@Component({
+	selector: 'hlm-switch',
+	imports: [BrnSwitchThumbComponent, BrnSwitchComponent, HlmSwitchThumbDirective],
+	standalone: true,
+	host: {
+		class: 'contents',
+		'[attr.id]': 'null',
+		'[attr.aria-label]': 'null',
+		'[attr.aria-labelledby]': 'null',
+		'[attr.aria-describedby]': 'null',
+	},
+	template: `
+		<brn-switch
+			[class]="_computedClass()"
+			[checked]="checked()"
+			(changed)="handleChange($event)"
+			(touched)="_onTouched?.()"
+			[disabled]="disabled()"
+			[id]="id()"
+			[aria-label]="ariaLabel()"
+			[aria-labelledby]="ariaLabelledby()"
+			[aria-describedby]="ariaDescribedby()"
+		>
+			<brn-switch-thumb hlm />
+		</brn-switch>
+	`,
+	providers: [HLM_SWITCH_VALUE_ACCESSOR],
+})
+export class HlmSwitchComponent {
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected readonly _computedClass = computed(() =>
+		hlm(
+			'group inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
+			this.disabled() ? 'cursor-not-allowed opacity-50' : '',
+			this.userClass(),
+		),
+	);
+
+	/** The checked state of the switch. */
+	public readonly checked = model<boolean>(false);
+
+	/** The disabled state of the switch. */
+	public readonly disabled = input<boolean, BooleanInput>(false, {
+		transform: booleanAttribute,
+	});
+
+	/** Used to set the id on the underlying brn element. */
+	public readonly id = input<string | null>(null);
+
+	/** Used to set the aria-label attribute on the underlying brn element. */
+	public readonly ariaLabel = input<string | null>(null, { alias: 'aria-label' });
+
+	/** Used to set the aria-labelledby attribute on the underlying brn element. */
+	public readonly ariaLabelledby = input<string | null>(null, { alias: 'aria-labelledby' });
+
+	/** Used to set the aria-describedby attribute on the underlying brn element. */
+	public readonly ariaDescribedby = input<string | null>(null, { alias: 'aria-describedby' });
+
+	/** Emits when the checked state of the switch changes. */
+	public readonly changed = output<boolean>();
+
+	protected _onChange?: ChangeFn<boolean>;
+	protected _onTouched?: TouchFn;
+
+	protected handleChange(value: boolean): void {
+		this.checked.set(value);
+		this._onChange?.(value);
+		this.changed.emit(value);
+	}
+
+	/** CONROL VALUE ACCESSOR */
+
+	writeValue(value: boolean): void {
+		this.checked.set(Boolean(value));
+	}
+
+	registerOnChange(fn: ChangeFn<boolean>): void {
+		this._onChange = fn;
+	}
+
+	registerOnTouched(fn: TouchFn): void {
+		this._onTouched = fn;
+	}
+}

+ 27 - 0
packages/admin-ui/src/lib/ui/ui-tabs-helm/src/index.ts

@@ -0,0 +1,27 @@
+import { NgModule } from '@angular/core';
+
+import { HlmTabsContentDirective } from './lib/hlm-tabs-content.directive';
+import { HlmTabsListComponent } from './lib/hlm-tabs-list.component';
+import { HlmTabsPaginatedListComponent } from './lib/hlm-tabs-paginated-list.component';
+import { HlmTabsTriggerDirective } from './lib/hlm-tabs-trigger.directive';
+import { HlmTabsComponent } from './lib/hlm-tabs.component';
+
+export * from './lib/hlm-tabs-content.directive';
+export * from './lib/hlm-tabs-list.component';
+export * from './lib/hlm-tabs-paginated-list.component';
+export * from './lib/hlm-tabs-trigger.directive';
+export * from './lib/hlm-tabs.component';
+
+export const HlmTabsImports = [
+	HlmTabsComponent,
+	HlmTabsListComponent,
+	HlmTabsTriggerDirective,
+	HlmTabsContentDirective,
+	HlmTabsPaginatedListComponent,
+] as const;
+
+@NgModule({
+	imports: [...HlmTabsImports],
+	exports: [...HlmTabsImports],
+})
+export class HlmTabsModule {}

+ 24 - 0
packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-content.directive.ts

@@ -0,0 +1,24 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { BrnTabsContentDirective } from '@spartan-ng/brain/tabs';
+import type { ClassValue } from 'clsx';
+
+@Directive({
+	selector: '[hlmTabsContent]',
+	standalone: true,
+	hostDirectives: [{ directive: BrnTabsContentDirective, inputs: ['brnTabsContent: hlmTabsContent'] }],
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmTabsContentDirective {
+	public readonly contentFor = input.required<string>({ alias: 'hlmTabsContent' });
+
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() =>
+		hlm(
+			'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
+			this.userClass(),
+		),
+	);
+}

+ 37 - 0
packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-list.component.ts

@@ -0,0 +1,37 @@
+import { Component, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { BrnTabsListDirective } from '@spartan-ng/brain/tabs';
+import { type VariantProps, cva } from 'class-variance-authority';
+import type { ClassValue } from 'clsx';
+
+export const listVariants = cva(
+	'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
+	{
+		variants: {
+			orientation: {
+				horizontal: 'h-10 space-x-1',
+				vertical: 'mt-2 flex-col h-fit space-y-1',
+			},
+		},
+		defaultVariants: {
+			orientation: 'horizontal',
+		},
+	},
+);
+type ListVariants = VariantProps<typeof listVariants>;
+
+@Component({
+	selector: 'hlm-tabs-list',
+	standalone: true,
+	hostDirectives: [BrnTabsListDirective],
+	template: '<ng-content/>',
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmTabsListComponent {
+	public readonly orientation = input<ListVariants['orientation']>('horizontal');
+
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() => hlm(listVariants({ orientation: this.orientation() }), this.userClass()));
+}

+ 95 - 0
packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-paginated-list.component.ts

@@ -0,0 +1,95 @@
+import { CdkObserveContent } from '@angular/cdk/observers';
+import { Component, type ElementRef, computed, contentChildren, input, viewChild } from '@angular/core';
+import { toObservable } from '@angular/core/rxjs-interop';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
+import { hlm } from '@spartan-ng/brain/core';
+import { BrnTabsPaginatedListDirective, BrnTabsTriggerDirective } from '@spartan-ng/brain/tabs';
+import { buttonVariants } from '@spartan-ng/ui-button-helm';
+import { HlmIconDirective } from '@spartan-ng/ui-icon-helm';
+import type { ClassValue } from 'clsx';
+import { listVariants } from './hlm-tabs-list.component';
+
+@Component({
+	selector: 'hlm-paginated-tabs-list',
+	standalone: true,
+	imports: [CdkObserveContent, NgIcon, HlmIconDirective],
+	providers: [provideIcons({ lucideChevronRight, lucideChevronLeft })],
+	template: `
+		<button
+			#previousPaginator
+			data-pagination="previous"
+			type="button"
+			aria-hidden="true"
+			tabindex="-1"
+			[class.flex]="_showPaginationControls()"
+			[class.hidden]="!_showPaginationControls()"
+			[class]="_paginationButtonClass()"
+			[disabled]="_disableScrollBefore || null"
+			(click)="_handlePaginatorClick('before')"
+			(mousedown)="_handlePaginatorPress('before', $event)"
+			(touchend)="_stopInterval()"
+		>
+			<ng-icon hlm size="base" name="lucideChevronLeft" />
+		</button>
+
+		<div #tabListContainer class="z-[1] flex grow overflow-hidden" (keydown)="_handleKeydown($event)">
+			<div class="relative grow transition-transform" #tabList role="tablist" (cdkObserveContent)="_onContentChanges()">
+				<div #tabListInner [class]="_tabListClass()">
+					<ng-content></ng-content>
+				</div>
+			</div>
+		</div>
+
+		<button
+			#nextPaginator
+			data-pagination="next"
+			type="button"
+			aria-hidden="true"
+			tabindex="-1"
+			[class.flex]="_showPaginationControls()"
+			[class.hidden]="!_showPaginationControls()"
+			[class]="_paginationButtonClass()"
+			[disabled]="_disableScrollAfter || null"
+			(click)="_handlePaginatorClick('after')"
+			(mousedown)="_handlePaginatorPress('after', $event)"
+			(touchend)="_stopInterval()"
+		>
+			<ng-icon hlm size="base" name="lucideChevronRight" />
+		</button>
+	`,
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmTabsPaginatedListComponent extends BrnTabsPaginatedListDirective {
+	public readonly _items = contentChildren(BrnTabsTriggerDirective, { descendants: false });
+	public readonly _itemsChanges = toObservable(this._items);
+
+	public readonly _tabListContainer = viewChild.required<ElementRef<HTMLElement>>('tabListContainer');
+	public readonly _tabList = viewChild.required<ElementRef<HTMLElement>>('tabList');
+	public readonly _tabListInner = viewChild.required<ElementRef<HTMLElement>>('tabListInner');
+	public readonly _nextPaginator = viewChild.required<ElementRef<HTMLElement>>('nextPaginator');
+	public readonly _previousPaginator = viewChild.required<ElementRef<HTMLElement>>('previousPaginator');
+
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected readonly _computedClass = computed(() =>
+		hlm('flex overflow-hidden relative gap-1 flex-shrink-0', this.userClass()),
+	);
+
+	public readonly tabLisClass = input<ClassValue>('', { alias: 'class' });
+	protected readonly _tabListClass = computed(() => hlm(listVariants(), this.tabLisClass()));
+
+	public readonly paginationButtonClass = input<ClassValue>('', { alias: 'class' });
+	protected readonly _paginationButtonClass = computed(() =>
+		hlm(
+			'relative z-[2] select-none disabled:cursor-default',
+			buttonVariants({ variant: 'ghost', size: 'icon' }),
+			this.paginationButtonClass(),
+		),
+	);
+
+	protected _itemSelected(event: KeyboardEvent) {
+		event.preventDefault();
+	}
+}

+ 24 - 0
packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs-trigger.directive.ts

@@ -0,0 +1,24 @@
+import { Directive, computed, input } from '@angular/core';
+import { hlm } from '@spartan-ng/brain/core';
+import { BrnTabsTriggerDirective } from '@spartan-ng/brain/tabs';
+import type { ClassValue } from 'clsx';
+
+@Directive({
+	selector: '[hlmTabsTrigger]',
+	standalone: true,
+	hostDirectives: [{ directive: BrnTabsTriggerDirective, inputs: ['brnTabsTrigger: hlmTabsTrigger', 'disabled'] }],
+	host: {
+		'[class]': '_computedClass()',
+	},
+})
+export class HlmTabsTriggerDirective {
+	public readonly triggerFor = input.required<string>({ alias: 'hlmTabsTrigger' });
+
+	public readonly userClass = input<ClassValue>('', { alias: 'class' });
+	protected _computedClass = computed(() =>
+		hlm(
+			'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
+			this.userClass(),
+		),
+	);
+}

+ 18 - 0
packages/admin-ui/src/lib/ui/ui-tabs-helm/src/lib/hlm-tabs.component.ts

@@ -0,0 +1,18 @@
+import { Component, input } from '@angular/core';
+import { BrnTabsDirective } from '@spartan-ng/brain/tabs';
+
+@Component({
+	selector: 'hlm-tabs',
+	standalone: true,
+	hostDirectives: [
+		{
+			directive: BrnTabsDirective,
+			inputs: ['orientation', 'direction', 'activationMode', 'brnTabs: tab'],
+			outputs: ['tabActivated'],
+		},
+	],
+	template: '<ng-content/>',
+})
+export class HlmTabsComponent {
+	public readonly tab = input.required<string>();
+}

+ 6 - 0
packages/admin-ui/tsconfig.json

@@ -92,6 +92,12 @@
       ],
       "@spartan-ng/ui-radiogroup-helm": [
         "./src/lib/ui/ui-radiogroup-helm/src/index.ts"
+      ],
+      "@spartan-ng/ui-menu-helm": [
+        "./src/lib/ui/ui-menu-helm/src/index.ts"
+      ],
+      "@spartan-ng/ui-breadcrumb-helm": [
+        "./src/lib/ui/ui-breadcrumb-helm/src/index.ts"
       ]
     },
     "useDefineForClassFields": false

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff