@besaitech/ng-design-system 0.0.16 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,9 @@ import { DomSanitizer } from '@angular/platform-browser';
4
4
  import * as i1 from '@angular/forms';
5
5
  import { NG_VALUE_ACCESSOR, NG_VALIDATORS, FormsModule } from '@angular/forms';
6
6
  import { NgTemplateOutlet } from '@angular/common';
7
- import { RouterLink, RouterLinkActive } from '@angular/router';
7
+ import { toSignal } from '@angular/core/rxjs-interop';
8
+ import { Router, NavigationEnd, RouterLink, RouterLinkActive } from '@angular/router';
9
+ import { filter, map } from 'rxjs';
8
10
 
9
11
  /**
10
12
  * Curated set of line icons (Lucide-style, 24x24 viewBox, stroke-based).
@@ -12,6 +14,17 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
12
14
  * Add new icons here and they become available everywhere via name="...".
13
15
  */
14
16
  const SDS_ICONS = {
17
+ // Added for the Belvigo merchant shell (nav + header). Lucide 24x24 stroke paths, matching the
18
+ // set the app already used before it moved onto this library.
19
+ 'chart-column': '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
20
+ 'clipboard-list': '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>',
21
+ 'credit-card': '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
22
+ 'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
23
+ 'languages': '<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>',
24
+ 'megaphone': '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
25
+ 'monitor': '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
26
+ 'store': '<path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/><path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7"/>',
27
+ 'user-round': '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
15
28
  home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
16
29
  'user-plus': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
17
30
  hand: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hand-icon lucide-hand"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>',
@@ -6501,29 +6514,72 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6501
6514
  }] });
6502
6515
 
6503
6516
  /**
6504
- * Application top bar: brand gradient with a decorative wave, a logo slot,
6505
- * the app name, and the signed-in user with a logout action.
6517
+ * Application top bar: a logo slot, the app name, and three slots the app fills.
6518
+ *
6519
+ * Two appearances. `brand` is the gradient-and-wave bar an internal tool wants. `surface` is white on
6520
+ * a hairline border, for a bar that has to sit under a customer-facing page without dominating it —
6521
+ * a storefront header is a bar, but it is not a banner. The slots and the layout are identical in
6522
+ * both; only the paint differs, so a consumer app gets the same structure without the brand fill.
6523
+ *
6524
+ * It deliberately owns NO account UI. It used to render the signed-in user and a logout button, which
6525
+ * forced every consumer into one shape — no dropdown, no email, no menu, no busy state, and an
6526
+ * icon-only button whose accessible name the library had to guess a language for. What an app needs
6527
+ * beside its own brand bar differs per app, so the bar supplies the surface and the app supplies the
6528
+ * contents: project them into `[sdsActions]`.
6529
+ *
6530
+ * Slots, left to right: `[sdsLogo]`, `[sdsLead]` (beside the name — a location or workspace picker),
6531
+ * `[sdsCenter]` (the flexible middle — a search field), `[sdsActions]` (the right edge).
6506
6532
  *
6507
6533
  * @example
6508
- * <sds-topbar appName="WebApp" [user]="user" (logout)="signOut()">
6534
+ * <sds-topbar appName="WebApp">
6509
6535
  * <img sdsLogo src="logo.svg" alt="" />
6536
+ * <my-address-picker sdsLead />
6537
+ * <my-search sdsCenter />
6538
+ * <my-account-menu sdsActions />
6510
6539
  * </sds-topbar>
6511
6540
  */
6512
6541
  class SdsTopbar {
6513
6542
  store = inject(SidebarStore);
6514
6543
  appName = input('', ...(ngDevMode ? [{ debugName: "appName" }] : []));
6515
- user = input(null, ...(ngDevMode ? [{ debugName: "user" }] : []));
6516
- showLogout = input(true, ...(ngDevMode ? [{ debugName: "showLogout", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
6544
+ /**
6545
+ * Width of the bar's CONTENTS, as Tailwind width utilities — e.g. `"w-full max-w-7xl"`.
6546
+ *
6547
+ * The coloured band always spans the viewport; this constrains only what sits on it, so the logo and
6548
+ * the actions line up with a page whose own content is centred at the same width. Default is empty,
6549
+ * i.e. contents run edge to edge, which is right for a full-bleed dashboard shell.
6550
+ *
6551
+ * A class string rather than an enum on purpose: apps already state their content width as a Tailwind
6552
+ * utility, and this way the bar is given the SAME string rather than a name that has to be kept in
6553
+ * sync with it.
6554
+ */
6555
+ contentWidth = input('w-full', ...(ngDevMode ? [{ debugName: "contentWidth" }] : []));
6517
6556
  /** Show the mobile hamburger that toggles the sidebar drawer (< lg). */
6518
6557
  menuButton = input(true, ...(ngDevMode ? [{ debugName: "menuButton", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
6519
- logout = output();
6558
+ /**
6559
+ * Accessible name for the hamburger. Defaults to French so nothing changes for existing consumers,
6560
+ * but any app serving more than one language MUST pass a translated value — an icon-only control's
6561
+ * aria-label is its only name, and a hardcoded one is simply wrong in every other locale.
6562
+ */
6563
+ menuLabel = input('Ouvrir le menu', ...(ngDevMode ? [{ debugName: "menuLabel" }] : []));
6564
+ /** `brand` (default) paints the gradient and the wave; `surface` is white on a hairline border. */
6565
+ appearance = input('brand', ...(ngDevMode ? [{ debugName: "appearance" }] : []));
6566
+ shellClasses = computed(() => this.appearance() === 'brand'
6567
+ ? 'bg-linear-to-r from-brand-800 via-brand-700 to-brand-600 text-white'
6568
+ : 'border-b border-border bg-surface text-ink', ...(ngDevMode ? [{ debugName: "shellClasses" }] : []));
6569
+ menuButtonClasses = computed(() => this.appearance() === 'brand'
6570
+ ? 'text-white/85 hover:bg-white/10 hover:text-white focus-visible:ring-white/40'
6571
+ : 'text-ink-soft hover:bg-muted hover:text-ink focus-visible:ring-brand-500/40', ...(ngDevMode ? [{ debugName: "menuButtonClasses" }] : []));
6520
6572
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsTopbar, deps: [], target: i0.ɵɵFactoryTarget.Component });
6521
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsTopbar, isStandalone: true, selector: "sds-topbar", inputs: { appName: { classPropertyName: "appName", publicName: "appName", isSignal: true, isRequired: false, transformFunction: null }, user: { classPropertyName: "user", publicName: "user", isSignal: true, isRequired: false, transformFunction: null }, showLogout: { classPropertyName: "showLogout", publicName: "showLogout", isSignal: true, isRequired: false, transformFunction: null }, menuButton: { classPropertyName: "menuButton", publicName: "menuButton", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { logout: "logout" }, host: { classAttribute: "block" }, ngImport: i0, template: `
6522
- <header
6523
- class="relative z-30 flex items-center justify-between overflow-hidden bg-linear-to-r from-brand-800 via-brand-700 to-brand-600 px-4 text-white sm:px-6"
6524
- style="height: var(--sds-topbar-h)"
6525
- >
6526
- <!-- Decorative wave -->
6573
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsTopbar, isStandalone: true, selector: "sds-topbar", inputs: { appName: { classPropertyName: "appName", publicName: "appName", isSignal: true, isRequired: false, transformFunction: null }, contentWidth: { classPropertyName: "contentWidth", publicName: "contentWidth", isSignal: true, isRequired: false, transformFunction: null }, menuButton: { classPropertyName: "menuButton", publicName: "menuButton", isSignal: true, isRequired: false, transformFunction: null }, menuLabel: { classPropertyName: "menuLabel", publicName: "menuLabel", isSignal: true, isRequired: false, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block" }, ngImport: i0, template: `
6574
+ <header class="relative z-30" [class]="shellClasses()" style="height: var(--sds-topbar-h)">
6575
+ <!--
6576
+ Decorative wave. The clip lives HERE, on the wave's own wrapper, and not on the header.
6577
+ The header used to carry overflow-hidden, which also clipped anything projected into
6578
+ [sdsActions] — so an app menu or dropdown, the very thing that slot exists for, rendered as a
6579
+ 4px sliver. The wave still needs clipping; nothing else does.
6580
+ -->
6581
+ @if (appearance() === 'brand') {
6582
+ <span class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
6527
6583
  <svg
6528
6584
  class="pointer-events-none absolute inset-y-0 right-0 h-full w-2/3 min-w-[420px]"
6529
6585
  viewBox="0 0 600 64"
@@ -6549,14 +6605,27 @@ class SdsTopbar {
6549
6605
  stroke-width="1.5"
6550
6606
  />
6551
6607
  </svg>
6608
+ </span>
6609
+ }
6552
6610
 
6611
+ <!--
6612
+ The band is always full-bleed — the gradient and the wave span the viewport — while the
6613
+ CONTENTS are constrained by [contentWidth], so a bar over a centred page lines up with it
6614
+ instead of running to the edges. Empty (the default) leaves the contents full-bleed too,
6615
+ which is what a dashboard shell wants.
6616
+ -->
6617
+ <div
6618
+ class="relative mx-auto flex h-full items-center justify-between px-4 sm:px-6"
6619
+ [class]="contentWidth()"
6620
+ >
6553
6621
  <!-- Left: mobile menu button + logo + app name -->
6554
6622
  <div class="relative flex items-center gap-3">
6555
6623
  @if (menuButton()) {
6556
6624
  <button
6557
6625
  type="button"
6558
- class="-ml-1 inline-flex h-9 w-9 items-center justify-center rounded-control text-white/85 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 lg:hidden"
6559
- aria-label="Ouvrir le menu"
6626
+ class="-ml-1 inline-flex h-9 w-9 items-center justify-center rounded-control transition-colors focus-visible:outline-none focus-visible:ring-2 lg:hidden"
6627
+ [class]="menuButtonClasses()"
6628
+ [attr.aria-label]="menuLabel()"
6560
6629
  (click)="store.toggleMobile()"
6561
6630
  >
6562
6631
  <sds-icon name="menu" [size]="22" />
@@ -6564,51 +6633,42 @@ class SdsTopbar {
6564
6633
  }
6565
6634
  <ng-content select="[sdsLogo]" />
6566
6635
  @if (appName()) {
6567
- <span class="h-7 w-px bg-white/30"></span>
6636
+ <span class="h-7 w-px" [class]="appearance() === 'brand' ? 'bg-white/30' : 'bg-border'"></span>
6568
6637
  <span class="text-xl font-bold tracking-tight">{{ appName() }}</span>
6569
6638
  }
6639
+ <ng-content select="[sdsLead]" />
6570
6640
  </div>
6571
6641
 
6572
- <!-- Right: actions + user + logout -->
6642
+ <!-- Flexible middle: takes the space the two ends leave, and is skipped entirely when empty. -->
6643
+ <div class="relative flex min-w-0 flex-1 items-center justify-center px-2">
6644
+ <ng-content select="[sdsCenter]" />
6645
+ </div>
6646
+
6647
+ <!-- Right: whatever the app puts here -->
6573
6648
  <div class="relative flex items-center gap-3 sm:gap-4">
6574
6649
  <ng-content select="[sdsActions]" />
6575
- @if (user(); as u) {
6576
- <span class="hidden text-sm font-medium sm:block">{{ u.name }}</span>
6577
- <sds-avatar
6578
- [name]="u.name"
6579
- [initials]="u.initials ?? ''"
6580
- [status]="u.status ?? 'online'"
6581
- [size]="38"
6582
- bgClass="bg-white/15"
6583
- />
6584
- }
6585
- @if (showLogout()) {
6586
- <button
6587
- type="button"
6588
- class="inline-flex h-9 w-9 items-center justify-center rounded-control text-white/85 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
6589
- aria-label="Se déconnecter"
6590
- (click)="logout.emit()"
6591
- >
6592
- <sds-icon name="log-out" [size]="20" />
6593
- </button>
6594
- }
6650
+ </div>
6595
6651
  </div>
6596
6652
  </header>
6597
- `, isInline: true, dependencies: [{ kind: "component", type: SdsAvatar, selector: "sds-avatar", inputs: ["name", "initials", "src", "size", "status", "bgClass"] }, { kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6653
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6598
6654
  }
6599
6655
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsTopbar, decorators: [{
6600
6656
  type: Component,
6601
6657
  args: [{
6602
6658
  selector: 'sds-topbar',
6603
6659
  changeDetection: ChangeDetectionStrategy.OnPush,
6604
- imports: [SdsAvatar, SdsIcon],
6660
+ imports: [SdsIcon],
6605
6661
  host: { class: 'block' },
6606
6662
  template: `
6607
- <header
6608
- class="relative z-30 flex items-center justify-between overflow-hidden bg-linear-to-r from-brand-800 via-brand-700 to-brand-600 px-4 text-white sm:px-6"
6609
- style="height: var(--sds-topbar-h)"
6610
- >
6611
- <!-- Decorative wave -->
6663
+ <header class="relative z-30" [class]="shellClasses()" style="height: var(--sds-topbar-h)">
6664
+ <!--
6665
+ Decorative wave. The clip lives HERE, on the wave's own wrapper, and not on the header.
6666
+ The header used to carry overflow-hidden, which also clipped anything projected into
6667
+ [sdsActions] — so an app menu or dropdown, the very thing that slot exists for, rendered as a
6668
+ 4px sliver. The wave still needs clipping; nothing else does.
6669
+ -->
6670
+ @if (appearance() === 'brand') {
6671
+ <span class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
6612
6672
  <svg
6613
6673
  class="pointer-events-none absolute inset-y-0 right-0 h-full w-2/3 min-w-[420px]"
6614
6674
  viewBox="0 0 600 64"
@@ -6634,14 +6694,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6634
6694
  stroke-width="1.5"
6635
6695
  />
6636
6696
  </svg>
6697
+ </span>
6698
+ }
6637
6699
 
6700
+ <!--
6701
+ The band is always full-bleed — the gradient and the wave span the viewport — while the
6702
+ CONTENTS are constrained by [contentWidth], so a bar over a centred page lines up with it
6703
+ instead of running to the edges. Empty (the default) leaves the contents full-bleed too,
6704
+ which is what a dashboard shell wants.
6705
+ -->
6706
+ <div
6707
+ class="relative mx-auto flex h-full items-center justify-between px-4 sm:px-6"
6708
+ [class]="contentWidth()"
6709
+ >
6638
6710
  <!-- Left: mobile menu button + logo + app name -->
6639
6711
  <div class="relative flex items-center gap-3">
6640
6712
  @if (menuButton()) {
6641
6713
  <button
6642
6714
  type="button"
6643
- class="-ml-1 inline-flex h-9 w-9 items-center justify-center rounded-control text-white/85 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 lg:hidden"
6644
- aria-label="Ouvrir le menu"
6715
+ class="-ml-1 inline-flex h-9 w-9 items-center justify-center rounded-control transition-colors focus-visible:outline-none focus-visible:ring-2 lg:hidden"
6716
+ [class]="menuButtonClasses()"
6717
+ [attr.aria-label]="menuLabel()"
6645
6718
  (click)="store.toggleMobile()"
6646
6719
  >
6647
6720
  <sds-icon name="menu" [size]="22" />
@@ -6649,39 +6722,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6649
6722
  }
6650
6723
  <ng-content select="[sdsLogo]" />
6651
6724
  @if (appName()) {
6652
- <span class="h-7 w-px bg-white/30"></span>
6725
+ <span class="h-7 w-px" [class]="appearance() === 'brand' ? 'bg-white/30' : 'bg-border'"></span>
6653
6726
  <span class="text-xl font-bold tracking-tight">{{ appName() }}</span>
6654
6727
  }
6728
+ <ng-content select="[sdsLead]" />
6729
+ </div>
6730
+
6731
+ <!-- Flexible middle: takes the space the two ends leave, and is skipped entirely when empty. -->
6732
+ <div class="relative flex min-w-0 flex-1 items-center justify-center px-2">
6733
+ <ng-content select="[sdsCenter]" />
6655
6734
  </div>
6656
6735
 
6657
- <!-- Right: actions + user + logout -->
6736
+ <!-- Right: whatever the app puts here -->
6658
6737
  <div class="relative flex items-center gap-3 sm:gap-4">
6659
6738
  <ng-content select="[sdsActions]" />
6660
- @if (user(); as u) {
6661
- <span class="hidden text-sm font-medium sm:block">{{ u.name }}</span>
6662
- <sds-avatar
6663
- [name]="u.name"
6664
- [initials]="u.initials ?? ''"
6665
- [status]="u.status ?? 'online'"
6666
- [size]="38"
6667
- bgClass="bg-white/15"
6668
- />
6669
- }
6670
- @if (showLogout()) {
6671
- <button
6672
- type="button"
6673
- class="inline-flex h-9 w-9 items-center justify-center rounded-control text-white/85 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
6674
- aria-label="Se déconnecter"
6675
- (click)="logout.emit()"
6676
- >
6677
- <sds-icon name="log-out" [size]="20" />
6678
- </button>
6679
- }
6739
+ </div>
6680
6740
  </div>
6681
6741
  </header>
6682
6742
  `,
6683
6743
  }]
6684
- }], propDecorators: { appName: [{ type: i0.Input, args: [{ isSignal: true, alias: "appName", required: false }] }], user: [{ type: i0.Input, args: [{ isSignal: true, alias: "user", required: false }] }], showLogout: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLogout", required: false }] }], menuButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "menuButton", required: false }] }], logout: [{ type: i0.Output, args: ["logout"] }] } });
6744
+ }], propDecorators: { appName: [{ type: i0.Input, args: [{ isSignal: true, alias: "appName", required: false }] }], contentWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentWidth", required: false }] }], menuButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "menuButton", required: false }] }], menuLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "menuLabel", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }] } });
6685
6745
 
6686
6746
  /**
6687
6747
  * App layout shell: a full-height column with the top bar across the top and a
@@ -6744,6 +6804,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6744
6804
  */
6745
6805
  class SdsSidebar {
6746
6806
  store = inject(SidebarStore);
6807
+ /**
6808
+ * Accessible names for the rail's three icon-only controls. French defaults keep existing consumers
6809
+ * unchanged; a multilingual app must pass translated values, since an icon-only button's aria-label
6810
+ * is its only name.
6811
+ */
6812
+ collapseLabel = input('Replier le menu', ...(ngDevMode ? [{ debugName: "collapseLabel" }] : []));
6813
+ expandLabel = input('Déplier le menu', ...(ngDevMode ? [{ debugName: "expandLabel" }] : []));
6814
+ closeLabel = input('Fermer le menu', ...(ngDevMode ? [{ debugName: "closeLabel" }] : []));
6747
6815
  collapsed = this.store.collapsed;
6748
6816
  mobileOpen = this.store.mobileOpen;
6749
6817
  /** Desktop rail width (full vs. collapsed); mobile always uses full width. */
@@ -6751,7 +6819,7 @@ class SdsSidebar {
6751
6819
  ? 'var(--sds-sidebar-w-collapsed)'
6752
6820
  : 'var(--sds-sidebar-w)', ...(ngDevMode ? [{ debugName: "railWidth" }] : []));
6753
6821
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSidebar, deps: [], target: i0.ɵɵFactoryTarget.Component });
6754
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsSidebar, isStandalone: true, selector: "sds-sidebar", host: { classAttribute: "block h-full" }, ngImport: i0, template: `
6822
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsSidebar, isStandalone: true, selector: "sds-sidebar", inputs: { collapseLabel: { classPropertyName: "collapseLabel", publicName: "collapseLabel", isSignal: true, isRequired: false, transformFunction: null }, expandLabel: { classPropertyName: "expandLabel", publicName: "expandLabel", isSignal: true, isRequired: false, transformFunction: null }, closeLabel: { classPropertyName: "closeLabel", publicName: "closeLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block h-full" }, ngImport: i0, template: `
6755
6823
  <!-- Mobile backdrop (only when the drawer is open, < lg) -->
6756
6824
  @if (mobileOpen()) {
6757
6825
  <div
@@ -6779,7 +6847,7 @@ class SdsSidebar {
6779
6847
  <button
6780
6848
  type="button"
6781
6849
  class="hidden h-9 w-9 items-center justify-center rounded-control text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 lg:inline-flex"
6782
- [attr.aria-label]="collapsed() ? 'Déplier le menu' : 'Replier le menu'"
6850
+ [attr.aria-label]="collapsed() ? expandLabel() : collapseLabel()"
6783
6851
  [attr.aria-expanded]="!collapsed()"
6784
6852
  (click)="store.toggle()"
6785
6853
  >
@@ -6791,7 +6859,7 @@ class SdsSidebar {
6791
6859
  <button
6792
6860
  type="button"
6793
6861
  class="inline-flex h-9 w-9 items-center justify-center rounded-control text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 lg:hidden"
6794
- aria-label="Fermer le menu"
6862
+ [attr.aria-label]="closeLabel()"
6795
6863
  (click)="store.closeMobile()"
6796
6864
  >
6797
6865
  <sds-icon name="x" [size]="20" />
@@ -6845,7 +6913,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6845
6913
  <button
6846
6914
  type="button"
6847
6915
  class="hidden h-9 w-9 items-center justify-center rounded-control text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 lg:inline-flex"
6848
- [attr.aria-label]="collapsed() ? 'Déplier le menu' : 'Replier le menu'"
6916
+ [attr.aria-label]="collapsed() ? expandLabel() : collapseLabel()"
6849
6917
  [attr.aria-expanded]="!collapsed()"
6850
6918
  (click)="store.toggle()"
6851
6919
  >
@@ -6857,7 +6925,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6857
6925
  <button
6858
6926
  type="button"
6859
6927
  class="inline-flex h-9 w-9 items-center justify-center rounded-control text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 lg:hidden"
6860
- aria-label="Fermer le menu"
6928
+ [attr.aria-label]="closeLabel()"
6861
6929
  (click)="store.closeMobile()"
6862
6930
  >
6863
6931
  <sds-icon name="x" [size]="20" />
@@ -6876,62 +6944,206 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6876
6944
  </aside>
6877
6945
  `,
6878
6946
  }]
6879
- }] });
6947
+ }], propDecorators: { collapseLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapseLabel", required: false }] }], expandLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandLabel", required: false }] }], closeLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeLabel", required: false }] }] } });
6880
6948
 
6881
6949
  /**
6882
- * Groups nav items under an optional uppercase section label. The label is
6883
- * hidden (replaced by a divider) when the sidebar is collapsed.
6950
+ * Groups nav items under an optional uppercase section label.
6951
+ *
6952
+ * Two modes:
6953
+ * - **Section** (default) — a static label over its items. The label is replaced by a divider when
6954
+ * the rail is collapsed.
6955
+ * - **Collapsible** (`[collapsible]`) — the label becomes a button that expands and collapses the
6956
+ * group. Set `[basePath]` and the group opens itself whenever the active route is inside it, so a
6957
+ * deep link or a browser Back lands with the right section already open instead of the person
6958
+ * having to find and re-open it.
6959
+ *
6960
+ * Collapsible mode exists because a flat rail stops scaling somewhere around a dozen destinations,
6961
+ * and the alternative — showing every child of every section at once — buries the ones in use.
6962
+ *
6963
+ * On the collapsed desktop rail there is nowhere to put a header, so a collapsible group renders
6964
+ * exactly like a section: divider, children visible. Hiding them there would leave the rail with
6965
+ * unreachable destinations and no affordance to reveal them.
6884
6966
  */
6885
6967
  class SdsNavGroup {
6886
6968
  store = inject(SidebarStore);
6969
+ router = inject(Router);
6887
6970
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
6971
+ /** Turn the label into an expand/collapse control. */
6972
+ collapsible = input(false, ...(ngDevMode ? [{ debugName: "collapsible", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
6973
+ /** Icon for a collapsible header, so it reads at the same weight as the items it sits among. */
6974
+ icon = input(null, ...(ngDevMode ? [{ debugName: "icon" }] : []));
6975
+ /**
6976
+ * URL prefix owned by this group, e.g. `/payments`. While the active route starts with it the
6977
+ * group counts as active and opens itself — unless the person has explicitly toggled it since.
6978
+ */
6979
+ basePath = input(null, ...(ngDevMode ? [{ debugName: "basePath" }] : []));
6980
+ /** Emits the new state on every manual toggle. Not emitted for route-driven auto-expansion. */
6981
+ expandedChange = output();
6982
+ /** Explicit user choice; null means "follow the route". */
6983
+ manual = signal(null, ...(ngDevMode ? [{ debugName: "manual" }] : []));
6984
+ url = toSignal(this.router.events.pipe(filter((e) => e instanceof NavigationEnd), map((e) => e.urlAfterRedirects)), { initialValue: this.router.url });
6985
+ /** True while the active route lives inside this group. */
6986
+ routeActive = computed(() => {
6987
+ const base = this.basePath();
6988
+ return !!base && this.url().startsWith(base);
6989
+ }, ...(ngDevMode ? [{ debugName: "routeActive" }] : []));
6990
+ expanded = computed(() => this.manual() ?? this.routeActive(), ...(ngDevMode ? [{ debugName: "expanded" }] : []));
6888
6991
  collapsed = this.store.collapsed;
6992
+ /**
6993
+ * A plain section always shows its items — they carry their own icons, so they survive the collapsed
6994
+ * rail. A collapsible group hides its children there instead: its children are sub-destinations and
6995
+ * typically have no icon, which on an icon-only rail is a row with nothing in it.
6996
+ */
6997
+ childrenVisible = computed(() => !this.collapsible() || (!this.collapsed() && this.expanded()), ...(ngDevMode ? [{ debugName: "childrenVisible" }] : []));
6998
+ /**
6999
+ * One binding rather than `[class]` alongside `[class.hidden]` — mixing a whole-list binding with a
7000
+ * single-class toggle on the same element depends on Angular's styling precedence to resolve, which
7001
+ * is a subtlety no template should rest on.
7002
+ *
7003
+ * The indent rule appears only on an expanded collapsible group on the full-width rail: it is what
7004
+ * shows the children belong to the header above them.
7005
+ */
7006
+ childrenClass = computed(() => {
7007
+ const parts = ['flex flex-col gap-1'];
7008
+ if (!this.childrenVisible()) {
7009
+ parts.push('hidden');
7010
+ }
7011
+ if (this.collapsible() && !this.collapsed()) {
7012
+ parts.push('ml-5 border-l border-white/10 pl-3');
7013
+ }
7014
+ return parts.join(' ');
7015
+ }, ...(ngDevMode ? [{ debugName: "childrenClass" }] : []));
7016
+ /**
7017
+ * On the collapsed rail there is nothing to expand into, so the toggle always reports "opening" —
7018
+ * the app's handler takes that as the cue to navigate into the section, which is the only useful
7019
+ * meaning the control can have there.
7020
+ */
7021
+ toggle() {
7022
+ const next = this.collapsed() ? true : !this.expanded();
7023
+ this.manual.set(next);
7024
+ this.expandedChange.emit(next);
7025
+ }
6889
7026
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsNavGroup, deps: [], target: i0.ɵɵFactoryTarget.Component });
6890
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsNavGroup, isStandalone: true, selector: "sds-nav-group", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block" }, ngImport: i0, template: `
7027
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsNavGroup, isStandalone: true, selector: "sds-nav-group", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, collapsible: { classPropertyName: "collapsible", publicName: "collapsible", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { expandedChange: "expandedChange" }, host: { classAttribute: "block" }, ngImport: i0, template: `
6891
7028
  <div class="mt-5 first:mt-3">
6892
7029
  @if (label()) {
6893
- @if (!collapsed()) {
6894
- <p
6895
- class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60"
7030
+ @if (collapsible()) {
7031
+ <!--
7032
+ A collapsible header is styled as a NAV ROW, not as a section caption. A small uppercase
7033
+ caption says "everything under me belongs to me" — which is a lie the moment the group is
7034
+ collapsed and the next section's items sit directly beneath it. At row scale, with an icon
7035
+ and a chevron, it reads as the control it is.
7036
+
7037
+ It renders on the collapsed rail too, as an icon alone, because there it is the group's
7038
+ ONLY representation: the children are hidden (a child with no icon of its own would be a
7039
+ blank row), so the app is expected to treat the toggle as "take me into this section" —
7040
+ which is what expandedChange already means.
7041
+ -->
7042
+ <button
7043
+ type="button"
7044
+ class="mb-1 flex h-10 w-full items-center gap-3 rounded-control px-3 text-sm font-medium
7045
+ text-white/70 transition-colors hover:bg-white/8 hover:text-white
7046
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
7047
+ [class.lg:justify-center]="collapsed()"
7048
+ [class.text-white]="routeActive()"
7049
+ [attr.aria-expanded]="collapsed() ? null : expanded()"
7050
+ [attr.title]="collapsed() ? label() : null"
7051
+ [attr.aria-label]="collapsed() ? label() : null"
7052
+ (click)="toggle()"
6896
7053
  >
7054
+ @if (icon()) {
7055
+ <sds-icon [name]="icon()!" [size]="20" class="shrink-0" />
7056
+ }
7057
+ <span class="flex-1 truncate text-left" [class.lg:hidden]="collapsed()">
7058
+ {{ label() }}
7059
+ </span>
7060
+ <sds-icon
7061
+ name="chevron-down"
7062
+ [size]="16"
7063
+ class="shrink-0 text-white/50 transition-transform"
7064
+ [class.lg:hidden]="collapsed()"
7065
+ [class.rotate-180]="expanded()"
7066
+ />
7067
+ </button>
7068
+ } @else if (collapsed()) {
7069
+ <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
7070
+ } @else {
7071
+ <p class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60">
6897
7072
  {{ label() }}
6898
7073
  </p>
6899
- } @else {
6900
- <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
6901
7074
  }
6902
7075
  }
6903
- <div class="flex flex-col gap-1">
7076
+
7077
+ <div [class]="childrenClass()">
6904
7078
  <ng-content />
6905
7079
  </div>
6906
7080
  </div>
6907
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
7081
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6908
7082
  }
6909
7083
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsNavGroup, decorators: [{
6910
7084
  type: Component,
6911
7085
  args: [{
6912
7086
  selector: 'sds-nav-group',
6913
7087
  changeDetection: ChangeDetectionStrategy.OnPush,
7088
+ imports: [SdsIcon],
6914
7089
  host: { class: 'block' },
6915
7090
  template: `
6916
7091
  <div class="mt-5 first:mt-3">
6917
7092
  @if (label()) {
6918
- @if (!collapsed()) {
6919
- <p
6920
- class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60"
7093
+ @if (collapsible()) {
7094
+ <!--
7095
+ A collapsible header is styled as a NAV ROW, not as a section caption. A small uppercase
7096
+ caption says "everything under me belongs to me" — which is a lie the moment the group is
7097
+ collapsed and the next section's items sit directly beneath it. At row scale, with an icon
7098
+ and a chevron, it reads as the control it is.
7099
+
7100
+ It renders on the collapsed rail too, as an icon alone, because there it is the group's
7101
+ ONLY representation: the children are hidden (a child with no icon of its own would be a
7102
+ blank row), so the app is expected to treat the toggle as "take me into this section" —
7103
+ which is what expandedChange already means.
7104
+ -->
7105
+ <button
7106
+ type="button"
7107
+ class="mb-1 flex h-10 w-full items-center gap-3 rounded-control px-3 text-sm font-medium
7108
+ text-white/70 transition-colors hover:bg-white/8 hover:text-white
7109
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
7110
+ [class.lg:justify-center]="collapsed()"
7111
+ [class.text-white]="routeActive()"
7112
+ [attr.aria-expanded]="collapsed() ? null : expanded()"
7113
+ [attr.title]="collapsed() ? label() : null"
7114
+ [attr.aria-label]="collapsed() ? label() : null"
7115
+ (click)="toggle()"
6921
7116
  >
7117
+ @if (icon()) {
7118
+ <sds-icon [name]="icon()!" [size]="20" class="shrink-0" />
7119
+ }
7120
+ <span class="flex-1 truncate text-left" [class.lg:hidden]="collapsed()">
7121
+ {{ label() }}
7122
+ </span>
7123
+ <sds-icon
7124
+ name="chevron-down"
7125
+ [size]="16"
7126
+ class="shrink-0 text-white/50 transition-transform"
7127
+ [class.lg:hidden]="collapsed()"
7128
+ [class.rotate-180]="expanded()"
7129
+ />
7130
+ </button>
7131
+ } @else if (collapsed()) {
7132
+ <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
7133
+ } @else {
7134
+ <p class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60">
6922
7135
  {{ label() }}
6923
7136
  </p>
6924
- } @else {
6925
- <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
6926
7137
  }
6927
7138
  }
6928
- <div class="flex flex-col gap-1">
7139
+
7140
+ <div [class]="childrenClass()">
6929
7141
  <ng-content />
6930
7142
  </div>
6931
7143
  </div>
6932
7144
  `,
6933
7145
  }]
6934
- }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }] } });
7146
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], collapsible: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsible", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], basePath: [{ type: i0.Input, args: [{ isSignal: true, alias: "basePath", required: false }] }], expandedChange: [{ type: i0.Output, args: ["expandedChange"] }] } });
6935
7147
 
6936
7148
  /**
6937
7149
  * A single sidebar navigation entry. Renders an anchor when [link] is set