@besaitech/ng-design-system 0.0.16 → 0.0.29

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,10 +14,24 @@ 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>',
18
31
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
32
+ // Couriers. The back-office and the courier-facing screens both name a rider, and every Belvigo
33
+ // surface that does should draw the same one.
34
+ bike: '<circle cx="18.5" cy="17.5" r="3.5"/><circle cx="5.5" cy="17.5" r="3.5"/><circle cx="15" cy="5" r="1"/><path d="M12 17.5V14l-3-3 4-3 2 3h2"/>',
19
35
  'building-2': '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/>',
20
36
  package: '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/>',
21
37
  'package-plus': '<path d="M16 16h6"/><path d="M19 13v6"/><path d="M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0"/><path d="m7.5 4.27 9 5.15"/><polyline points="3.29 7 12 12 20.71 7"/><path d="M12 22V12"/>',
@@ -260,11 +276,33 @@ class SdsInput {
260
276
  placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
261
277
  hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
262
278
  error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
279
+ /**
280
+ * `search` is the semantically right type for a search field, and the template strips the WebKit
281
+ * search decorations that come with it. Without that, a `search` field with {@link clearable} draws
282
+ * TWO clear buttons — the component's and the browser's own, which no stylesheet can reach and which
283
+ * sits outside the field's own padding.
284
+ */
263
285
  type = input('text', ...(ngDevMode ? [{ debugName: "type" }] : []));
264
286
  size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
287
+ /**
288
+ * `filled` is a borderless field on a muted ground, for a search box that has to sit inside a page
289
+ * rather than announce itself as a form control — a storefront's search pill above all.
290
+ * `on-brand` is the same field tuned for a dark surface.
291
+ */
265
292
  appearance = input('default', ...(ngDevMode ? [{ debugName: "appearance" }] : []));
293
+ /**
294
+ * `pill` fully rounds the field. Separate from {@link appearance} on purpose: a bordered pill and a
295
+ * filled box are both real designs, and folding the two axes into one enum would forbid half of them.
296
+ */
297
+ shape = input('control', ...(ngDevMode ? [{ debugName: "shape" }] : []));
266
298
  disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
267
299
  clearable = input(false, ...(ngDevMode ? [{ debugName: "clearable", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
300
+ /**
301
+ * Accessible name for the clear button. French default so existing consumers are unchanged, but an
302
+ * app serving more than one language MUST translate it — it is an icon-only control, so this string
303
+ * is its only name.
304
+ */
305
+ clearLabel = input('Effacer', ...(ngDevMode ? [{ debugName: "clearLabel" }] : []));
268
306
  leadingIcon = input(null, ...(ngDevMode ? [{ debugName: "leadingIcon" }] : []));
269
307
  trailingIcon = input(null, ...(ngDevMode ? [{ debugName: "trailingIcon" }] : []));
270
308
  /**
@@ -286,6 +324,8 @@ class SdsInput {
286
324
  value = signal('', ...(ngDevMode ? [{ debugName: "value" }] : []));
287
325
  cvaDisabled = signal(false, ...(ngDevMode ? [{ debugName: "cvaDisabled" }] : []));
288
326
  onBrand = computed(() => this.appearance() === 'on-brand', ...(ngDevMode ? [{ debugName: "onBrand" }] : []));
327
+ filled = computed(() => this.appearance() === 'filled', ...(ngDevMode ? [{ debugName: "filled" }] : []));
328
+ pill = computed(() => this.shape() === 'pill', ...(ngDevMode ? [{ debugName: "pill" }] : []));
289
329
  isDisabled = computed(() => this.disabled() || this.cvaDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
290
330
  describedBy = computed(() => {
291
331
  if (this.error())
@@ -295,15 +335,29 @@ class SdsInput {
295
335
  return null;
296
336
  }, ...(ngDevMode ? [{ debugName: "describedBy" }] : []));
297
337
  fieldPadding = computed(() => {
298
- const left = this.leadingIcon() ? 'pl-10' : 'pl-3.5';
299
- const right = this.trailingIcon() || this.clearable() || this.trailingAction() ? 'pr-10' : 'pr-3.5';
338
+ const inset = this.pill() ? 'pl-11' : 'pl-10';
339
+ const bare = this.pill() ? 'pl-5' : 'pl-3.5';
340
+ const left = this.leadingIcon() ? inset : bare;
341
+ const right = this.trailingIcon() || this.clearable() || this.trailingAction()
342
+ ? 'pr-10'
343
+ : this.pill()
344
+ ? 'pr-5'
345
+ : 'pr-3.5';
300
346
  return `${left} ${right}`;
301
347
  }, ...(ngDevMode ? [{ debugName: "fieldPadding" }] : []));
302
348
  wrapperClasses = computed(() => {
303
- const parts = [SIZES$8[this.size()]];
349
+ const parts = [SIZES$8[this.size()], this.pill() ? 'rounded-full' : 'rounded-control'];
304
350
  if (this.isDisabled())
305
351
  parts.push('opacity-60 cursor-not-allowed');
306
- if (this.onBrand()) {
352
+ if (this.filled()) {
353
+ // border-transparent, not border-0: the border box is what keeps a filled field the same height
354
+ // as a bordered one, so the two sit level when a toolbar mixes them.
355
+ parts.push('border-transparent bg-muted text-ink');
356
+ parts.push(this.error()
357
+ ? 'border-danger focus-within:ring-2 focus-within:ring-danger/30'
358
+ : 'focus-within:ring-2 focus-within:ring-brand-500/25');
359
+ }
360
+ else if (this.onBrand()) {
307
361
  parts.push('bg-white/10 text-white');
308
362
  parts.push(this.error()
309
363
  ? 'border-danger focus-within:ring-2 focus-within:ring-danger/30'
@@ -343,7 +397,7 @@ class SdsInput {
343
397
  this.onTouched();
344
398
  }
345
399
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
346
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsInput, isStandalone: true, selector: "sds-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, clearable: { classPropertyName: "clearable", publicName: "clearable", isSignal: true, isRequired: false, transformFunction: null }, leadingIcon: { classPropertyName: "leadingIcon", publicName: "leadingIcon", isSignal: true, isRequired: false, transformFunction: null }, trailingIcon: { classPropertyName: "trailingIcon", publicName: "trailingIcon", isSignal: true, isRequired: false, transformFunction: null }, trailingAction: { classPropertyName: "trailingAction", publicName: "trailingAction", isSignal: true, isRequired: false, transformFunction: null }, trailingActionLabel: { classPropertyName: "trailingActionLabel", publicName: "trailingActionLabel", isSignal: true, isRequired: false, transformFunction: null }, trailingActionPressed: { classPropertyName: "trailingActionPressed", publicName: "trailingActionPressed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { trailingActionClick: "trailingActionClick" }, host: { classAttribute: "block" }, providers: [
400
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsInput, isStandalone: true, selector: "sds-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, shape: { classPropertyName: "shape", publicName: "shape", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, clearable: { classPropertyName: "clearable", publicName: "clearable", isSignal: true, isRequired: false, transformFunction: null }, clearLabel: { classPropertyName: "clearLabel", publicName: "clearLabel", isSignal: true, isRequired: false, transformFunction: null }, leadingIcon: { classPropertyName: "leadingIcon", publicName: "leadingIcon", isSignal: true, isRequired: false, transformFunction: null }, trailingIcon: { classPropertyName: "trailingIcon", publicName: "trailingIcon", isSignal: true, isRequired: false, transformFunction: null }, trailingAction: { classPropertyName: "trailingAction", publicName: "trailingAction", isSignal: true, isRequired: false, transformFunction: null }, trailingActionLabel: { classPropertyName: "trailingActionLabel", publicName: "trailingActionLabel", isSignal: true, isRequired: false, transformFunction: null }, trailingActionPressed: { classPropertyName: "trailingActionPressed", publicName: "trailingActionPressed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { trailingActionClick: "trailingActionClick" }, host: { classAttribute: "block" }, providers: [
347
401
  {
348
402
  provide: NG_VALUE_ACCESSOR,
349
403
  useExisting: forwardRef(() => SdsInput),
@@ -361,14 +415,17 @@ class SdsInput {
361
415
  }
362
416
 
363
417
  <div
364
- class="relative flex items-center rounded-control border transition-colors"
418
+ class="relative flex items-center border transition-colors"
365
419
  [class]="wrapperClasses()"
366
420
  >
367
421
  @if (leadingIcon()) {
422
+ <!-- A pill needs its icon further in: on a fully-rounded field an icon at left-3 sits over
423
+ the curve rather than inside the field. -->
368
424
  <sds-icon
369
425
  [name]="leadingIcon()!"
370
426
  [size]="18"
371
- class="pointer-events-none absolute left-3 opacity-70"
427
+ class="pointer-events-none absolute opacity-70"
428
+ [class]="pill() ? 'left-4' : 'left-3'"
372
429
  />
373
430
  }
374
431
 
@@ -381,7 +438,9 @@ class SdsInput {
381
438
  [attr.aria-label]="!label() && ariaLabel() ? ariaLabel() : null"
382
439
  [attr.aria-invalid]="error() ? 'true' : null"
383
440
  [attr.aria-describedby]="describedBy()"
384
- class="w-full bg-transparent outline-none placeholder:text-current/50 disabled:cursor-not-allowed"
441
+ class="w-full bg-transparent outline-none placeholder:text-current/50 disabled:cursor-not-allowed
442
+ [&::-webkit-search-cancel-button]:appearance-none
443
+ [&::-webkit-search-decoration]:appearance-none"
385
444
  [class]="fieldPadding()"
386
445
  (input)="onInput($event)"
387
446
  (blur)="onTouched()"
@@ -391,7 +450,7 @@ class SdsInput {
391
450
  <button
392
451
  type="button"
393
452
  tabindex="-1"
394
- aria-label="Effacer"
453
+ [attr.aria-label]="clearLabel()"
395
454
  class="absolute right-2.5 inline-flex h-6 w-6 items-center justify-center rounded-full opacity-60 hover:opacity-100 hover:bg-black/5"
396
455
  (click)="clear()"
397
456
  >
@@ -459,14 +518,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
459
518
  }
460
519
 
461
520
  <div
462
- class="relative flex items-center rounded-control border transition-colors"
521
+ class="relative flex items-center border transition-colors"
463
522
  [class]="wrapperClasses()"
464
523
  >
465
524
  @if (leadingIcon()) {
525
+ <!-- A pill needs its icon further in: on a fully-rounded field an icon at left-3 sits over
526
+ the curve rather than inside the field. -->
466
527
  <sds-icon
467
528
  [name]="leadingIcon()!"
468
529
  [size]="18"
469
- class="pointer-events-none absolute left-3 opacity-70"
530
+ class="pointer-events-none absolute opacity-70"
531
+ [class]="pill() ? 'left-4' : 'left-3'"
470
532
  />
471
533
  }
472
534
 
@@ -479,7 +541,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
479
541
  [attr.aria-label]="!label() && ariaLabel() ? ariaLabel() : null"
480
542
  [attr.aria-invalid]="error() ? 'true' : null"
481
543
  [attr.aria-describedby]="describedBy()"
482
- class="w-full bg-transparent outline-none placeholder:text-current/50 disabled:cursor-not-allowed"
544
+ class="w-full bg-transparent outline-none placeholder:text-current/50 disabled:cursor-not-allowed
545
+ [&::-webkit-search-cancel-button]:appearance-none
546
+ [&::-webkit-search-decoration]:appearance-none"
483
547
  [class]="fieldPadding()"
484
548
  (input)="onInput($event)"
485
549
  (blur)="onTouched()"
@@ -489,7 +553,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
489
553
  <button
490
554
  type="button"
491
555
  tabindex="-1"
492
- aria-label="Effacer"
556
+ [attr.aria-label]="clearLabel()"
493
557
  class="absolute right-2.5 inline-flex h-6 w-6 items-center justify-center rounded-full opacity-60 hover:opacity-100 hover:bg-black/5"
494
558
  (click)="clear()"
495
559
  >
@@ -531,7 +595,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
531
595
  }
532
596
  `,
533
597
  }]
534
- }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], clearable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearable", required: false }] }], leadingIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "leadingIcon", required: false }] }], trailingIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingIcon", required: false }] }], trailingAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingAction", required: false }] }], trailingActionLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingActionLabel", required: false }] }], trailingActionPressed: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingActionPressed", required: false }] }], trailingActionClick: [{ type: i0.Output, args: ["trailingActionClick"] }] } });
598
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], clearable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearable", required: false }] }], clearLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearLabel", required: false }] }], leadingIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "leadingIcon", required: false }] }], trailingIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingIcon", required: false }] }], trailingAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingAction", required: false }] }], trailingActionLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingActionLabel", required: false }] }], trailingActionPressed: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingActionPressed", required: false }] }], trailingActionClick: [{ type: i0.Output, args: ["trailingActionClick"] }] } });
535
599
 
536
600
  let nextId$9 = 0;
537
601
  const SIZES$7 = {
@@ -6501,29 +6565,72 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6501
6565
  }] });
6502
6566
 
6503
6567
  /**
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.
6568
+ * Application top bar: a logo slot, the app name, and three slots the app fills.
6569
+ *
6570
+ * Two appearances. `brand` is the gradient-and-wave bar an internal tool wants. `surface` is white on
6571
+ * a hairline border, for a bar that has to sit under a customer-facing page without dominating it —
6572
+ * a storefront header is a bar, but it is not a banner. The slots and the layout are identical in
6573
+ * both; only the paint differs, so a consumer app gets the same structure without the brand fill.
6574
+ *
6575
+ * It deliberately owns NO account UI. It used to render the signed-in user and a logout button, which
6576
+ * forced every consumer into one shape — no dropdown, no email, no menu, no busy state, and an
6577
+ * icon-only button whose accessible name the library had to guess a language for. What an app needs
6578
+ * beside its own brand bar differs per app, so the bar supplies the surface and the app supplies the
6579
+ * contents: project them into `[sdsActions]`.
6580
+ *
6581
+ * Slots, left to right: `[sdsLogo]`, `[sdsLead]` (beside the name — a location or workspace picker),
6582
+ * `[sdsCenter]` (the flexible middle — a search field), `[sdsActions]` (the right edge).
6506
6583
  *
6507
6584
  * @example
6508
- * <sds-topbar appName="WebApp" [user]="user" (logout)="signOut()">
6585
+ * <sds-topbar appName="WebApp">
6509
6586
  * <img sdsLogo src="logo.svg" alt="" />
6587
+ * <my-address-picker sdsLead />
6588
+ * <my-search sdsCenter />
6589
+ * <my-account-menu sdsActions />
6510
6590
  * </sds-topbar>
6511
6591
  */
6512
6592
  class SdsTopbar {
6513
6593
  store = inject(SidebarStore);
6514
6594
  appName = input('', ...(ngDevMode ? [{ debugName: "appName" }] : []));
6515
- user = input(null, ...(ngDevMode ? [{ debugName: "user" }] : []));
6516
- showLogout = input(true, ...(ngDevMode ? [{ debugName: "showLogout", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
6595
+ /**
6596
+ * Width of the bar's CONTENTS, as Tailwind width utilities — e.g. `"w-full max-w-7xl"`.
6597
+ *
6598
+ * The coloured band always spans the viewport; this constrains only what sits on it, so the logo and
6599
+ * the actions line up with a page whose own content is centred at the same width. Default is empty,
6600
+ * i.e. contents run edge to edge, which is right for a full-bleed dashboard shell.
6601
+ *
6602
+ * A class string rather than an enum on purpose: apps already state their content width as a Tailwind
6603
+ * utility, and this way the bar is given the SAME string rather than a name that has to be kept in
6604
+ * sync with it.
6605
+ */
6606
+ contentWidth = input('w-full', ...(ngDevMode ? [{ debugName: "contentWidth" }] : []));
6517
6607
  /** Show the mobile hamburger that toggles the sidebar drawer (< lg). */
6518
6608
  menuButton = input(true, ...(ngDevMode ? [{ debugName: "menuButton", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
6519
- logout = output();
6609
+ /**
6610
+ * Accessible name for the hamburger. Defaults to French so nothing changes for existing consumers,
6611
+ * but any app serving more than one language MUST pass a translated value — an icon-only control's
6612
+ * aria-label is its only name, and a hardcoded one is simply wrong in every other locale.
6613
+ */
6614
+ menuLabel = input('Ouvrir le menu', ...(ngDevMode ? [{ debugName: "menuLabel" }] : []));
6615
+ /** `brand` (default) paints the gradient and the wave; `surface` is white on a hairline border. */
6616
+ appearance = input('brand', ...(ngDevMode ? [{ debugName: "appearance" }] : []));
6617
+ shellClasses = computed(() => this.appearance() === 'brand'
6618
+ ? 'bg-linear-to-r from-brand-800 via-brand-700 to-brand-600 text-white'
6619
+ : 'border-b border-border bg-surface text-ink', ...(ngDevMode ? [{ debugName: "shellClasses" }] : []));
6620
+ menuButtonClasses = computed(() => this.appearance() === 'brand'
6621
+ ? 'text-white/85 hover:bg-white/10 hover:text-white focus-visible:ring-white/40'
6622
+ : 'text-ink-soft hover:bg-muted hover:text-ink focus-visible:ring-brand-500/40', ...(ngDevMode ? [{ debugName: "menuButtonClasses" }] : []));
6520
6623
  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 -->
6624
+ 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: `
6625
+ <header class="relative z-30" [class]="shellClasses()" style="height: var(--sds-topbar-h)">
6626
+ <!--
6627
+ Decorative wave. The clip lives HERE, on the wave's own wrapper, and not on the header.
6628
+ The header used to carry overflow-hidden, which also clipped anything projected into
6629
+ [sdsActions] — so an app menu or dropdown, the very thing that slot exists for, rendered as a
6630
+ 4px sliver. The wave still needs clipping; nothing else does.
6631
+ -->
6632
+ @if (appearance() === 'brand') {
6633
+ <span class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
6527
6634
  <svg
6528
6635
  class="pointer-events-none absolute inset-y-0 right-0 h-full w-2/3 min-w-[420px]"
6529
6636
  viewBox="0 0 600 64"
@@ -6549,66 +6656,80 @@ class SdsTopbar {
6549
6656
  stroke-width="1.5"
6550
6657
  />
6551
6658
  </svg>
6659
+ </span>
6660
+ }
6552
6661
 
6662
+ <!--
6663
+ The band is always full-bleed — the gradient and the wave span the viewport — while the
6664
+ CONTENTS are constrained by [contentWidth], so a bar over a centred page lines up with it
6665
+ instead of running to the edges. Empty (the default) leaves the contents full-bleed too,
6666
+ which is what a dashboard shell wants.
6667
+ -->
6668
+ <div
6669
+ class="relative mx-auto flex h-full items-center justify-between px-4 sm:px-6"
6670
+ [class]="contentWidth()"
6671
+ >
6553
6672
  <!-- Left: mobile menu button + logo + app name -->
6554
6673
  <div class="relative flex items-center gap-3">
6555
6674
  @if (menuButton()) {
6556
6675
  <button
6557
6676
  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"
6677
+ 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"
6678
+ [class]="menuButtonClasses()"
6679
+ [attr.aria-label]="menuLabel()"
6560
6680
  (click)="store.toggleMobile()"
6561
6681
  >
6562
6682
  <sds-icon name="menu" [size]="22" />
6563
6683
  </button>
6564
6684
  }
6565
6685
  <ng-content select="[sdsLogo]" />
6686
+ <!--
6687
+ shrink-0 + whitespace-nowrap, both required. The left block is a flex row, so without them a
6688
+ two-word app name wraps to a second line INSIDE a bar whose height is fixed at
6689
+ --sds-topbar-h, and the name is clipped by its own bar. It surfaced at 375px on a bar
6690
+ carrying a logo, a wordmark and a two-syllable name; it is wrong at every width.
6691
+
6692
+ The name never truncates and never hides: it is the answer to "which app am I in", which on a
6693
+ platform of five same-branded apps is the one thing the bar must not drop. An app whose left
6694
+ block is too crowded for a phone hides ITS OWN wordmark under sm and keeps the mark.
6695
+ -->
6566
6696
  @if (appName()) {
6567
- <span class="h-7 w-px bg-white/30"></span>
6568
- <span class="text-xl font-bold tracking-tight">{{ appName() }}</span>
6697
+ <span class="h-7 w-px shrink-0" [class]="appearance() === 'brand' ? 'bg-white/30' : 'bg-border'"></span>
6698
+ <span class="shrink-0 whitespace-nowrap text-xl font-bold tracking-tight">{{ appName() }}</span>
6569
6699
  }
6700
+ <ng-content select="[sdsLead]" />
6701
+ </div>
6702
+
6703
+ <!-- Flexible middle: takes the space the two ends leave, and is skipped entirely when empty. -->
6704
+ <div class="relative flex min-w-0 flex-1 items-center justify-center px-2">
6705
+ <ng-content select="[sdsCenter]" />
6570
6706
  </div>
6571
6707
 
6572
- <!-- Right: actions + user + logout -->
6708
+ <!-- Right: whatever the app puts here -->
6573
6709
  <div class="relative flex items-center gap-3 sm:gap-4">
6574
6710
  <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
- }
6711
+ </div>
6595
6712
  </div>
6596
6713
  </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 });
6714
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6598
6715
  }
6599
6716
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsTopbar, decorators: [{
6600
6717
  type: Component,
6601
6718
  args: [{
6602
6719
  selector: 'sds-topbar',
6603
6720
  changeDetection: ChangeDetectionStrategy.OnPush,
6604
- imports: [SdsAvatar, SdsIcon],
6721
+ imports: [SdsIcon],
6605
6722
  host: { class: 'block' },
6606
6723
  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 -->
6724
+ <header class="relative z-30" [class]="shellClasses()" style="height: var(--sds-topbar-h)">
6725
+ <!--
6726
+ Decorative wave. The clip lives HERE, on the wave's own wrapper, and not on the header.
6727
+ The header used to carry overflow-hidden, which also clipped anything projected into
6728
+ [sdsActions] — so an app menu or dropdown, the very thing that slot exists for, rendered as a
6729
+ 4px sliver. The wave still needs clipping; nothing else does.
6730
+ -->
6731
+ @if (appearance() === 'brand') {
6732
+ <span class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
6612
6733
  <svg
6613
6734
  class="pointer-events-none absolute inset-y-0 right-0 h-full w-2/3 min-w-[420px]"
6614
6735
  viewBox="0 0 600 64"
@@ -6634,54 +6755,64 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6634
6755
  stroke-width="1.5"
6635
6756
  />
6636
6757
  </svg>
6758
+ </span>
6759
+ }
6637
6760
 
6761
+ <!--
6762
+ The band is always full-bleed — the gradient and the wave span the viewport — while the
6763
+ CONTENTS are constrained by [contentWidth], so a bar over a centred page lines up with it
6764
+ instead of running to the edges. Empty (the default) leaves the contents full-bleed too,
6765
+ which is what a dashboard shell wants.
6766
+ -->
6767
+ <div
6768
+ class="relative mx-auto flex h-full items-center justify-between px-4 sm:px-6"
6769
+ [class]="contentWidth()"
6770
+ >
6638
6771
  <!-- Left: mobile menu button + logo + app name -->
6639
6772
  <div class="relative flex items-center gap-3">
6640
6773
  @if (menuButton()) {
6641
6774
  <button
6642
6775
  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"
6776
+ 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"
6777
+ [class]="menuButtonClasses()"
6778
+ [attr.aria-label]="menuLabel()"
6645
6779
  (click)="store.toggleMobile()"
6646
6780
  >
6647
6781
  <sds-icon name="menu" [size]="22" />
6648
6782
  </button>
6649
6783
  }
6650
6784
  <ng-content select="[sdsLogo]" />
6785
+ <!--
6786
+ shrink-0 + whitespace-nowrap, both required. The left block is a flex row, so without them a
6787
+ two-word app name wraps to a second line INSIDE a bar whose height is fixed at
6788
+ --sds-topbar-h, and the name is clipped by its own bar. It surfaced at 375px on a bar
6789
+ carrying a logo, a wordmark and a two-syllable name; it is wrong at every width.
6790
+
6791
+ The name never truncates and never hides: it is the answer to "which app am I in", which on a
6792
+ platform of five same-branded apps is the one thing the bar must not drop. An app whose left
6793
+ block is too crowded for a phone hides ITS OWN wordmark under sm and keeps the mark.
6794
+ -->
6651
6795
  @if (appName()) {
6652
- <span class="h-7 w-px bg-white/30"></span>
6653
- <span class="text-xl font-bold tracking-tight">{{ appName() }}</span>
6796
+ <span class="h-7 w-px shrink-0" [class]="appearance() === 'brand' ? 'bg-white/30' : 'bg-border'"></span>
6797
+ <span class="shrink-0 whitespace-nowrap text-xl font-bold tracking-tight">{{ appName() }}</span>
6654
6798
  }
6799
+ <ng-content select="[sdsLead]" />
6655
6800
  </div>
6656
6801
 
6657
- <!-- Right: actions + user + logout -->
6802
+ <!-- Flexible middle: takes the space the two ends leave, and is skipped entirely when empty. -->
6803
+ <div class="relative flex min-w-0 flex-1 items-center justify-center px-2">
6804
+ <ng-content select="[sdsCenter]" />
6805
+ </div>
6806
+
6807
+ <!-- Right: whatever the app puts here -->
6658
6808
  <div class="relative flex items-center gap-3 sm:gap-4">
6659
6809
  <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
- }
6810
+ </div>
6680
6811
  </div>
6681
6812
  </header>
6682
6813
  `,
6683
6814
  }]
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"] }] } });
6815
+ }], 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
6816
 
6686
6817
  /**
6687
6818
  * App layout shell: a full-height column with the top bar across the top and a
@@ -6744,6 +6875,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6744
6875
  */
6745
6876
  class SdsSidebar {
6746
6877
  store = inject(SidebarStore);
6878
+ /**
6879
+ * Accessible names for the rail's three icon-only controls. French defaults keep existing consumers
6880
+ * unchanged; a multilingual app must pass translated values, since an icon-only button's aria-label
6881
+ * is its only name.
6882
+ */
6883
+ collapseLabel = input('Replier le menu', ...(ngDevMode ? [{ debugName: "collapseLabel" }] : []));
6884
+ expandLabel = input('Déplier le menu', ...(ngDevMode ? [{ debugName: "expandLabel" }] : []));
6885
+ closeLabel = input('Fermer le menu', ...(ngDevMode ? [{ debugName: "closeLabel" }] : []));
6747
6886
  collapsed = this.store.collapsed;
6748
6887
  mobileOpen = this.store.mobileOpen;
6749
6888
  /** Desktop rail width (full vs. collapsed); mobile always uses full width. */
@@ -6751,7 +6890,7 @@ class SdsSidebar {
6751
6890
  ? 'var(--sds-sidebar-w-collapsed)'
6752
6891
  : 'var(--sds-sidebar-w)', ...(ngDevMode ? [{ debugName: "railWidth" }] : []));
6753
6892
  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: `
6893
+ 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
6894
  <!-- Mobile backdrop (only when the drawer is open, < lg) -->
6756
6895
  @if (mobileOpen()) {
6757
6896
  <div
@@ -6779,7 +6918,7 @@ class SdsSidebar {
6779
6918
  <button
6780
6919
  type="button"
6781
6920
  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'"
6921
+ [attr.aria-label]="collapsed() ? expandLabel() : collapseLabel()"
6783
6922
  [attr.aria-expanded]="!collapsed()"
6784
6923
  (click)="store.toggle()"
6785
6924
  >
@@ -6791,7 +6930,7 @@ class SdsSidebar {
6791
6930
  <button
6792
6931
  type="button"
6793
6932
  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"
6933
+ [attr.aria-label]="closeLabel()"
6795
6934
  (click)="store.closeMobile()"
6796
6935
  >
6797
6936
  <sds-icon name="x" [size]="20" />
@@ -6845,7 +6984,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6845
6984
  <button
6846
6985
  type="button"
6847
6986
  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'"
6987
+ [attr.aria-label]="collapsed() ? expandLabel() : collapseLabel()"
6849
6988
  [attr.aria-expanded]="!collapsed()"
6850
6989
  (click)="store.toggle()"
6851
6990
  >
@@ -6857,7 +6996,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6857
6996
  <button
6858
6997
  type="button"
6859
6998
  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"
6999
+ [attr.aria-label]="closeLabel()"
6861
7000
  (click)="store.closeMobile()"
6862
7001
  >
6863
7002
  <sds-icon name="x" [size]="20" />
@@ -6876,62 +7015,206 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
6876
7015
  </aside>
6877
7016
  `,
6878
7017
  }]
6879
- }] });
7018
+ }], 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
7019
 
6881
7020
  /**
6882
- * Groups nav items under an optional uppercase section label. The label is
6883
- * hidden (replaced by a divider) when the sidebar is collapsed.
7021
+ * Groups nav items under an optional uppercase section label.
7022
+ *
7023
+ * Two modes:
7024
+ * - **Section** (default) — a static label over its items. The label is replaced by a divider when
7025
+ * the rail is collapsed.
7026
+ * - **Collapsible** (`[collapsible]`) — the label becomes a button that expands and collapses the
7027
+ * group. Set `[basePath]` and the group opens itself whenever the active route is inside it, so a
7028
+ * deep link or a browser Back lands with the right section already open instead of the person
7029
+ * having to find and re-open it.
7030
+ *
7031
+ * Collapsible mode exists because a flat rail stops scaling somewhere around a dozen destinations,
7032
+ * and the alternative — showing every child of every section at once — buries the ones in use.
7033
+ *
7034
+ * On the collapsed desktop rail there is nowhere to put a header, so a collapsible group renders
7035
+ * exactly like a section: divider, children visible. Hiding them there would leave the rail with
7036
+ * unreachable destinations and no affordance to reveal them.
6884
7037
  */
6885
7038
  class SdsNavGroup {
6886
7039
  store = inject(SidebarStore);
7040
+ router = inject(Router);
6887
7041
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
7042
+ /** Turn the label into an expand/collapse control. */
7043
+ collapsible = input(false, ...(ngDevMode ? [{ debugName: "collapsible", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
7044
+ /** Icon for a collapsible header, so it reads at the same weight as the items it sits among. */
7045
+ icon = input(null, ...(ngDevMode ? [{ debugName: "icon" }] : []));
7046
+ /**
7047
+ * URL prefix owned by this group, e.g. `/payments`. While the active route starts with it the
7048
+ * group counts as active and opens itself — unless the person has explicitly toggled it since.
7049
+ */
7050
+ basePath = input(null, ...(ngDevMode ? [{ debugName: "basePath" }] : []));
7051
+ /** Emits the new state on every manual toggle. Not emitted for route-driven auto-expansion. */
7052
+ expandedChange = output();
7053
+ /** Explicit user choice; null means "follow the route". */
7054
+ manual = signal(null, ...(ngDevMode ? [{ debugName: "manual" }] : []));
7055
+ url = toSignal(this.router.events.pipe(filter((e) => e instanceof NavigationEnd), map((e) => e.urlAfterRedirects)), { initialValue: this.router.url });
7056
+ /** True while the active route lives inside this group. */
7057
+ routeActive = computed(() => {
7058
+ const base = this.basePath();
7059
+ return !!base && this.url().startsWith(base);
7060
+ }, ...(ngDevMode ? [{ debugName: "routeActive" }] : []));
7061
+ expanded = computed(() => this.manual() ?? this.routeActive(), ...(ngDevMode ? [{ debugName: "expanded" }] : []));
6888
7062
  collapsed = this.store.collapsed;
7063
+ /**
7064
+ * A plain section always shows its items — they carry their own icons, so they survive the collapsed
7065
+ * rail. A collapsible group hides its children there instead: its children are sub-destinations and
7066
+ * typically have no icon, which on an icon-only rail is a row with nothing in it.
7067
+ */
7068
+ childrenVisible = computed(() => !this.collapsible() || (!this.collapsed() && this.expanded()), ...(ngDevMode ? [{ debugName: "childrenVisible" }] : []));
7069
+ /**
7070
+ * One binding rather than `[class]` alongside `[class.hidden]` — mixing a whole-list binding with a
7071
+ * single-class toggle on the same element depends on Angular's styling precedence to resolve, which
7072
+ * is a subtlety no template should rest on.
7073
+ *
7074
+ * The indent rule appears only on an expanded collapsible group on the full-width rail: it is what
7075
+ * shows the children belong to the header above them.
7076
+ */
7077
+ childrenClass = computed(() => {
7078
+ const parts = ['flex flex-col gap-1'];
7079
+ if (!this.childrenVisible()) {
7080
+ parts.push('hidden');
7081
+ }
7082
+ if (this.collapsible() && !this.collapsed()) {
7083
+ parts.push('ml-5 border-l border-white/10 pl-3');
7084
+ }
7085
+ return parts.join(' ');
7086
+ }, ...(ngDevMode ? [{ debugName: "childrenClass" }] : []));
7087
+ /**
7088
+ * On the collapsed rail there is nothing to expand into, so the toggle always reports "opening" —
7089
+ * the app's handler takes that as the cue to navigate into the section, which is the only useful
7090
+ * meaning the control can have there.
7091
+ */
7092
+ toggle() {
7093
+ const next = this.collapsed() ? true : !this.expanded();
7094
+ this.manual.set(next);
7095
+ this.expandedChange.emit(next);
7096
+ }
6889
7097
  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: `
7098
+ 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
7099
  <div class="mt-5 first:mt-3">
6892
7100
  @if (label()) {
6893
- @if (!collapsed()) {
6894
- <p
6895
- class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60"
7101
+ @if (collapsible()) {
7102
+ <!--
7103
+ A collapsible header is styled as a NAV ROW, not as a section caption. A small uppercase
7104
+ caption says "everything under me belongs to me" — which is a lie the moment the group is
7105
+ collapsed and the next section's items sit directly beneath it. At row scale, with an icon
7106
+ and a chevron, it reads as the control it is.
7107
+
7108
+ It renders on the collapsed rail too, as an icon alone, because there it is the group's
7109
+ ONLY representation: the children are hidden (a child with no icon of its own would be a
7110
+ blank row), so the app is expected to treat the toggle as "take me into this section" —
7111
+ which is what expandedChange already means.
7112
+ -->
7113
+ <button
7114
+ type="button"
7115
+ class="mb-1 flex h-10 w-full items-center gap-3 rounded-control px-3 text-sm font-medium
7116
+ text-white/70 transition-colors hover:bg-white/8 hover:text-white
7117
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
7118
+ [class.lg:justify-center]="collapsed()"
7119
+ [class.text-white]="routeActive()"
7120
+ [attr.aria-expanded]="collapsed() ? null : expanded()"
7121
+ [attr.title]="collapsed() ? label() : null"
7122
+ [attr.aria-label]="collapsed() ? label() : null"
7123
+ (click)="toggle()"
6896
7124
  >
7125
+ @if (icon()) {
7126
+ <sds-icon [name]="icon()!" [size]="20" class="shrink-0" />
7127
+ }
7128
+ <span class="flex-1 truncate text-left" [class.lg:hidden]="collapsed()">
7129
+ {{ label() }}
7130
+ </span>
7131
+ <sds-icon
7132
+ name="chevron-down"
7133
+ [size]="16"
7134
+ class="shrink-0 text-white/50 transition-transform"
7135
+ [class.lg:hidden]="collapsed()"
7136
+ [class.rotate-180]="expanded()"
7137
+ />
7138
+ </button>
7139
+ } @else if (collapsed()) {
7140
+ <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
7141
+ } @else {
7142
+ <p class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60">
6897
7143
  {{ label() }}
6898
7144
  </p>
6899
- } @else {
6900
- <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
6901
7145
  }
6902
7146
  }
6903
- <div class="flex flex-col gap-1">
7147
+
7148
+ <div [class]="childrenClass()">
6904
7149
  <ng-content />
6905
7150
  </div>
6906
7151
  </div>
6907
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
7152
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6908
7153
  }
6909
7154
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsNavGroup, decorators: [{
6910
7155
  type: Component,
6911
7156
  args: [{
6912
7157
  selector: 'sds-nav-group',
6913
7158
  changeDetection: ChangeDetectionStrategy.OnPush,
7159
+ imports: [SdsIcon],
6914
7160
  host: { class: 'block' },
6915
7161
  template: `
6916
7162
  <div class="mt-5 first:mt-3">
6917
7163
  @if (label()) {
6918
- @if (!collapsed()) {
6919
- <p
6920
- class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60"
7164
+ @if (collapsible()) {
7165
+ <!--
7166
+ A collapsible header is styled as a NAV ROW, not as a section caption. A small uppercase
7167
+ caption says "everything under me belongs to me" — which is a lie the moment the group is
7168
+ collapsed and the next section's items sit directly beneath it. At row scale, with an icon
7169
+ and a chevron, it reads as the control it is.
7170
+
7171
+ It renders on the collapsed rail too, as an icon alone, because there it is the group's
7172
+ ONLY representation: the children are hidden (a child with no icon of its own would be a
7173
+ blank row), so the app is expected to treat the toggle as "take me into this section" —
7174
+ which is what expandedChange already means.
7175
+ -->
7176
+ <button
7177
+ type="button"
7178
+ class="mb-1 flex h-10 w-full items-center gap-3 rounded-control px-3 text-sm font-medium
7179
+ text-white/70 transition-colors hover:bg-white/8 hover:text-white
7180
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
7181
+ [class.lg:justify-center]="collapsed()"
7182
+ [class.text-white]="routeActive()"
7183
+ [attr.aria-expanded]="collapsed() ? null : expanded()"
7184
+ [attr.title]="collapsed() ? label() : null"
7185
+ [attr.aria-label]="collapsed() ? label() : null"
7186
+ (click)="toggle()"
6921
7187
  >
7188
+ @if (icon()) {
7189
+ <sds-icon [name]="icon()!" [size]="20" class="shrink-0" />
7190
+ }
7191
+ <span class="flex-1 truncate text-left" [class.lg:hidden]="collapsed()">
7192
+ {{ label() }}
7193
+ </span>
7194
+ <sds-icon
7195
+ name="chevron-down"
7196
+ [size]="16"
7197
+ class="shrink-0 text-white/50 transition-transform"
7198
+ [class.lg:hidden]="collapsed()"
7199
+ [class.rotate-180]="expanded()"
7200
+ />
7201
+ </button>
7202
+ } @else if (collapsed()) {
7203
+ <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
7204
+ } @else {
7205
+ <p class="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-wider text-white/60">
6922
7206
  {{ label() }}
6923
7207
  </p>
6924
- } @else {
6925
- <div class="mx-3 mb-2 mt-2 h-px bg-white/10"></div>
6926
7208
  }
6927
7209
  }
6928
- <div class="flex flex-col gap-1">
7210
+
7211
+ <div [class]="childrenClass()">
6929
7212
  <ng-content />
6930
7213
  </div>
6931
7214
  </div>
6932
7215
  `,
6933
7216
  }]
6934
- }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }] } });
7217
+ }], 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
7218
 
6936
7219
  /**
6937
7220
  * A single sidebar navigation entry. Renders an anchor when [link] is set