@besaitech/ng-design-system 0.0.51 → 0.0.53

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, input, computed, ChangeDetectionStrategy, Component, booleanAttribute, isDevMode, effect, numberAttribute, output, signal, forwardRef, viewChild, viewChildren, afterRenderEffect, ElementRef, model, Injector, afterNextRender, TemplateRef, Directive, contentChild, Injectable, contentChildren, untracked, DestroyRef } from '@angular/core';
2
+ import { inject, input, computed, ChangeDetectionStrategy, Component, booleanAttribute, isDevMode, effect, numberAttribute, output, signal, forwardRef, viewChild, viewChildren, afterRenderEffect, model, ElementRef, Directive, Injector, contentChild, untracked, afterNextRender, TemplateRef, linkedSignal, Injectable, contentChildren, DestroyRef } from '@angular/core';
3
3
  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';
@@ -108,6 +108,43 @@ const SDS_ICONS = {
108
108
  'search-x': '<path d="m13.5 8.5-5 5"/><path d="m8.5 8.5 5 5"/><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
109
109
  command: '<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>',
110
110
  sparkles: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>',
111
+ // Added for 0.0.53 (Lucide 24x24 stroke paths): status glyphs for sds-checklist-row / sds-score /
112
+ // sds-status-tile (distinct SHAPES per status, so status never rides on colour), the sds-menu trigger
113
+ // ellipses, the sds-sortable-list grip, sds-file-drop / sds-image-cropper glyphs, and common row actions.
114
+ 'triangle-alert': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
115
+ 'octagon-x': '<path d="m15 9-6 6"/><path d="M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z"/><path d="m9 9 6 6"/>',
116
+ 'ban': '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
117
+ 'ellipsis': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
118
+ 'ellipsis-vertical': '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
119
+ 'grip-vertical': '<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>',
120
+ 'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
121
+ 'image': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
122
+ 'zoom-in': '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>',
123
+ 'zoom-out': '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="8" x2="14" y1="11" y2="11"/>',
124
+ 'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
125
+ 'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
126
+ 'pencil': '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
127
+ // A question mark in a circle: the "may contain" mark on sds-tri-state-chip, and a generic help glyph.
128
+ 'circle-help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
129
+ // Stock / inventory (the kitchen stock board, the sold-out toolkit). Original glyph: three stacked boxes.
130
+ stock: '<rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/><rect x="8" y="3" width="8" height="8" rx="1"/><path d="M6 13v2.5h2V13"/><path d="M16 13v2.5h2V13"/><path d="M11 3v2.5h2V3"/>',
131
+ // The 14 EU allergens of Regulation (EU) 1169/2011 Annex II, in Annex order. Original Belvigo drawings
132
+ // (Lucide has none), same grammar as the rest of the set: 24x24, stroke only, 2px, round caps/joins.
133
+ // Names mirror the API enum (CEREALS_GLUTEN -> allergen-cereals-gluten); sds-allergen-icon maps them.
134
+ 'allergen-cereals-gluten': '<path d="M12 22V8"/><path d="M12 8c-1.6-1.6-1.6-4.2 0-6 1.6 1.8 1.6 4.4 0 6z"/><path d="M12 13c-2.6 0-4.6-1.6-5.2-4.2 2.6 0 4.6 1.6 5.2 4.2z"/><path d="M12 13c2.6 0 4.6-1.6 5.2-4.2-2.6 0-4.6 1.6-5.2 4.2z"/><path d="M12 18c-2.6 0-4.6-1.6-5.2-4.2 2.6 0 4.6 1.6 5.2 4.2z"/><path d="M12 18c2.6 0 4.6-1.6 5.2-4.2-2.6 0-4.6 1.6-5.2 4.2z"/>',
135
+ 'allergen-crustaceans': '<ellipse cx="12" cy="15" rx="6" ry="4"/><path d="M3.5 4a2.5 2.5 0 1 0 5 0"/><path d="M15.5 4a2.5 2.5 0 1 0 5 0"/><path d="M6 6.5 8.5 11.8"/><path d="M18 6.5 15.5 11.8"/><path d="M6 15H3"/><path d="M18 15h3"/><path d="M7.3 17.5 4.5 20"/><path d="M16.7 17.5 19.5 20"/><path d="M10.5 11.2V9.5"/><path d="M13.5 11.2V9.5"/>',
136
+ 'allergen-eggs': '<path d="M12 2.5c-3.6 0-6.5 5.5-6.5 10.5a6.5 6.5 0 0 0 13 0c0-5-2.9-10.5-6.5-10.5z"/><path d="M9 13.5a3 3 0 0 0 2.5 3"/>',
137
+ 'allergen-fish': '<path d="M2.5 12c2-3.5 5-5.5 8.5-5.5 3 0 5.5 2 7 5.5-1.5 3.5-4 5.5-7 5.5-3.5 0-6.5-2-8.5-5.5z"/><path d="M18 12l3.5-3.5v7z"/><path d="M10 9a4.5 4.5 0 0 1 0 6"/><path d="M6.5 11h.01"/>',
138
+ 'allergen-peanuts': '<path d="M8.8 12a5 5 0 1 1 6.4 0 5 5 0 1 1-6.4 0z"/><path d="M10.5 6.5h.01"/><path d="M13.5 8.5h.01"/><path d="M10.5 15.5h.01"/><path d="M13.5 17.5h.01"/>',
139
+ 'allergen-soybeans': '<path d="M15.88 3.88 3.88 15.88a3 3 0 0 0 4.24 4.24L20.12 8.12a3 3 0 0 0-4.24-4.24z"/><circle cx="8.5" cy="15.5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="15.5" cy="8.5" r="1.5"/><path d="M20.5 3.5 22 2"/>',
140
+ 'allergen-milk': '<path d="M6 10v10.5A1.5 1.5 0 0 0 7.5 22h9a1.5 1.5 0 0 0 1.5-1.5V10"/><path d="M6 10l3-5h6l3 5z"/><path d="M9 5V2.5h6V5"/><path d="M12 13.5c-1 1.3-1.5 2.1-1.5 2.8a1.5 1.5 0 0 0 3 0c0-.7-.5-1.5-1.5-2.8z"/>',
141
+ 'allergen-nuts': '<path d="M5 9.5C5 6.5 8 4 12 4s7 2.5 7 5.5z"/><path d="M6.5 9.5v1.5c0 4.5 2.5 8.5 5.5 10 3-1.5 5.5-5.5 5.5-10V9.5"/><path d="M12 4c0-1 .5-1.7 1.5-2"/>',
142
+ 'allergen-celery': '<path d="M7 22c-.8-4-.5-8.5 1-12.5h8c1.5 4 1.8 8.5 1 12.5z"/><path d="M10.5 22c-.3-4-.2-8.5.5-12.5"/><path d="M13.5 22c.3-4 .2-8.5-.5-12.5"/><path d="M9.5 9.5 7.5 6.5"/><path d="M12 9.5V5"/><path d="M14.5 9.5l2-3"/><circle cx="6.5" cy="5" r="1.5"/><circle cx="12" cy="3.5" r="1.5"/><circle cx="17.5" cy="5" r="1.5"/>',
143
+ 'allergen-mustard': '<rect x="5" y="7" width="14" height="3" rx="1"/><path d="M6 10v8.5A3.5 3.5 0 0 0 9.5 22h5a3.5 3.5 0 0 0 3.5-3.5V10"/><path d="M13.5 7l3-4.5"/><rect x="9" y="13" width="6" height="5" rx="1"/>',
144
+ 'allergen-sesame': '<path d="M7 3c1.2 1.2 1.8 2.5 1.8 3.5a1.8 1.8 0 0 1-3.6 0C5.2 5.5 5.8 4.2 7 3z" transform="rotate(-25 7 6)"/><path d="M16.5 3.5c1.2 1.2 1.8 2.5 1.8 3.5a1.8 1.8 0 0 1-3.6 0c0-1 .6-2.3 1.8-3.5z" transform="rotate(30 16.5 6.5)"/><path d="M8.5 13c1.2 1.2 1.8 2.5 1.8 3.5a1.8 1.8 0 0 1-3.6 0c0-1 .6-2.3 1.8-3.5z" transform="rotate(15 8.5 16)"/><path d="M17 12c1.2 1.2 1.8 2.5 1.8 3.5a1.8 1.8 0 0 1-3.6 0c0-1 .6-2.3 1.8-3.5z" transform="rotate(-20 17 15)"/>',
145
+ 'allergen-sulphites': '<path d="M7.5 3h9c.3 1.5.5 3 .5 4.5a5 5 0 0 1-10 0c0-1.5.2-3 .5-4.5z"/><path d="M12 12.5V21"/><path d="M8.5 21h7"/><path d="M7.2 7h9.6"/>',
146
+ 'allergen-lupin': '<path d="M12 2.5a1.7 1.7 0 0 1 1.2 3 1.8 1.8 0 0 1 .8 3 1.9 1.9 0 0 1 .8 3 2 2 0 0 1-.3 2.8H9.5a2 2 0 0 1-.3-2.8 1.9 1.9 0 0 1 .8-3 1.8 1.8 0 0 1 .8-3 1.7 1.7 0 0 1 1.2-3z"/><path d="M10.8 8.5h2.4"/><path d="M10.2 11.5h3.6"/><path d="M12 14.3V22"/><path d="M12 19.5 7 17.5"/><path d="M12 19.5 8.5 16"/><path d="M12 19.5l5-2"/><path d="M12 19.5l3.5-3.5"/>',
147
+ 'allergen-molluscs': '<path d="M12 19 4.5 11a7.5 7.5 0 0 1 15 0z"/><path d="M12 19V3.5"/><path d="M12 19 7.2 5.2"/><path d="M12 19l4.8-13.8"/><path d="M9.5 22 12 19l2.5 3z"/>',
111
148
  };
112
149
 
113
150
  const SIZE_PX = { sm: 16, md: 20, lg: 24, xl: 32 };
@@ -293,7 +330,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
293
330
  `, styles: [".sds-spinner{width:1em;height:1em;border:2px solid currentColor;border-right-color:transparent;border-radius:9999px;animation:sds-spin .6s linear infinite}@keyframes sds-spin{to{transform:rotate(360deg)}}\n"] }]
294
331
  }], ctorParameters: () => [], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], block: [{ type: i0.Input, args: [{ isSignal: true, alias: "block", required: false }] }], iconOnly: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconOnly", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }] } });
295
332
 
296
- let nextId$f = 0;
333
+ let nextId$l = 0;
297
334
  const SIZES$a = {
298
335
  sm: 'h-9 text-sm',
299
336
  md: 'h-10 text-sm',
@@ -306,7 +343,7 @@ const SIZES$a = {
306
343
  * sidebar search field).
307
344
  */
308
345
  class SdsInput {
309
- inputId = `sds-input-${nextId$f++}`;
346
+ inputId = `sds-input-${nextId$l++}`;
310
347
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
311
348
  /** Accessible name used when no visible [label] is provided. */
312
349
  ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
@@ -639,7 +676,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
639
676
  `, styles: ["input:-webkit-autofill,input:-webkit-autofill:hover,input:-webkit-autofill:focus,input:-webkit-autofill:active{transition:background-color 100000s ease-in-out 0s;-webkit-text-fill-color:currentColor;caret-color:currentColor}\n"] }]
640
677
  }], 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 }] }], autocomplete: [{ type: i0.Input, args: [{ isSignal: true, alias: "autocomplete", required: false }] }], maxLength: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxLength", 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"] }] } });
641
678
 
642
- let nextId$e = 0;
679
+ let nextId$k = 0;
643
680
  const HEIGHTS = {
644
681
  sm: 'h-9',
645
682
  md: 'h-10',
@@ -662,7 +699,7 @@ const TEXT = {
662
699
  * {@link min}/{@link max} on blur and on every spinner step; ArrowUp / ArrowDown also step it.
663
700
  */
664
701
  class SdsInputNumber {
665
- inputId = `sds-input-number-${nextId$e++}`;
702
+ inputId = `sds-input-number-${nextId$k++}`;
666
703
  fieldRef = viewChild('field', ...(ngDevMode ? [{ debugName: "fieldRef" }] : []));
667
704
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
668
705
  /** Accessible name used when no visible [label] is provided. */
@@ -835,6 +872,20 @@ class SdsInputNumber {
835
872
  el.value = v === null ? '' : this.format(v);
836
873
  });
837
874
  }
875
+ /**
876
+ * Focus the field, painting the current value into it first (the live-format effect stands down while
877
+ * the field is focused, so a value written just before focusing would otherwise not show). With
878
+ * `select`, the text is selected — handy for an in-cell editor.
879
+ */
880
+ focus(options = {}) {
881
+ const el = this.fieldRef()?.nativeElement;
882
+ if (!el)
883
+ return;
884
+ this.paintCommitted();
885
+ el.focus();
886
+ if (options.select)
887
+ el.select();
888
+ }
838
889
  isActive(el) {
839
890
  return typeof document !== 'undefined' && document.activeElement === el;
840
891
  }
@@ -1369,7 +1420,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
1369
1420
  }]
1370
1421
  }], ctorParameters: () => [], propDecorators: { fieldRef: [{ type: i0.ViewChild, args: ['field', { isSignal: true }] }], 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 }] }], 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 }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], currency: [{ type: i0.Input, args: [{ isSignal: true, alias: "currency", required: false }] }], currencyDisplay: [{ type: i0.Input, args: [{ isSignal: true, alias: "currencyDisplay", required: false }] }], useGrouping: [{ type: i0.Input, args: [{ isSignal: true, alias: "useGrouping", required: false }] }], minFractionDigits: [{ type: i0.Input, args: [{ isSignal: true, alias: "minFractionDigits", required: false }] }], maxFractionDigits: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFractionDigits", required: false }] }], prefix: [{ type: i0.Input, args: [{ isSignal: true, alias: "prefix", required: false }] }], suffix: [{ type: i0.Input, args: [{ isSignal: true, alias: "suffix", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], step: [{ type: i0.Input, args: [{ isSignal: true, alias: "step", required: false }] }], showButtons: [{ type: i0.Input, args: [{ isSignal: true, alias: "showButtons", required: false }] }], buttonLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "buttonLayout", required: false }] }], incrementIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "incrementIcon", required: false }] }], decrementIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "decrementIcon", required: false }] }], allowEmpty: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowEmpty", required: false }] }], clearable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearable", required: false }] }], clearLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearLabel", required: false }] }], incrementLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "incrementLabel", required: false }] }], decrementLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "decrementLabel", required: false }] }] } });
1371
1422
 
1372
- let nextId$d = 0;
1423
+ let nextId$j = 0;
1373
1424
  const SIZES$9 = {
1374
1425
  sm: 'min-h-9 text-sm',
1375
1426
  md: 'min-h-10 text-sm',
@@ -1389,7 +1440,7 @@ const SIZES$9 = {
1389
1440
  * `<sds-localized-input [languages]="['fr','nl','en']" [(ngModel)]="title" />`.
1390
1441
  */
1391
1442
  class SdsLocalizedInput {
1392
- inputId = `sds-localized-input-${nextId$d++}`;
1443
+ inputId = `sds-localized-input-${nextId$j++}`;
1393
1444
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
1394
1445
  hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
1395
1446
  placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
@@ -1645,7 +1696,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
1645
1696
  }]
1646
1697
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], languages: [{ type: i0.Input, args: [{ isSignal: true, alias: "languages", required: false }] }], currentLanguage: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentLanguage", required: false }] }], multiline: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiline", required: false }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], languageSwitcherLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "languageSwitcherLabel", required: false }] }], untranslatedLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "untranslatedLabel", required: false }] }] } });
1647
1698
 
1648
- let nextId$c = 0;
1699
+ let nextId$i = 0;
1649
1700
  const SIZES$8 = {
1650
1701
  md: 'h-12 w-11 text-lg',
1651
1702
  lg: 'h-14 w-12 text-xl',
@@ -1662,7 +1713,7 @@ const SIZES$8 = {
1662
1713
  * first box and have it land correctly.
1663
1714
  */
1664
1715
  class SdsOtpInput {
1665
- groupId = `sds-otp-${nextId$c++}`;
1716
+ groupId = `sds-otp-${nextId$i++}`;
1666
1717
  /** How many boxes / characters the code has. */
1667
1718
  length = input(6, ...(ngDevMode ? [{ debugName: "length", transform: numberAttribute }] : [{ transform: numberAttribute }]));
1668
1719
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
@@ -1962,7 +2013,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
1962
2013
  }]
1963
2014
  }], ctorParameters: () => [], propDecorators: { length: [{ type: i0.Input, args: [{ isSignal: true, alias: "length", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], inputMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputMode", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoFocus", required: false }] }], cellLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "cellLabel", required: false }] }], completed: [{ type: i0.Output, args: ["completed"] }], cells: [{ type: i0.ViewChildren, args: ['cell', { isSignal: true }] }] } });
1964
2015
 
1965
- let nextId$b = 0;
2016
+ let nextId$h = 0;
1966
2017
  const SIZES$7 = {
1967
2018
  sm: 'text-sm px-3 py-2',
1968
2019
  md: 'text-sm px-3.5 py-2.5',
@@ -1974,7 +2025,7 @@ const SIZES$7 = {
1974
2025
  * [(ngModel)].
1975
2026
  */
1976
2027
  class SdsTextarea {
1977
- fieldId = `sds-textarea-${nextId$b++}`;
2028
+ fieldId = `sds-textarea-${nextId$h++}`;
1978
2029
  field = viewChild('field', ...(ngDevMode ? [{ debugName: "field" }] : []));
1979
2030
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
1980
2031
  /** Accessible name used when no visible [label] is provided. */
@@ -2227,7 +2278,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
2227
2278
  }]
2228
2279
  }], ctorParameters: () => [], propDecorators: { field: [{ type: i0.ViewChild, args: ['field', { isSignal: true }] }], 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 }] }], 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 }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: false }] }], autoResize: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoResize", required: false }] }], maxLength: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxLength", required: false }] }], showCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCount", required: false }] }], resize: [{ type: i0.Input, args: [{ isSignal: true, alias: "resize", required: false }] }] } });
2229
2280
 
2230
- let nextId$a = 0;
2281
+ let nextId$g = 0;
2231
2282
  const SIZES$6 = {
2232
2283
  sm: { track: 'h-5 w-9', knob: 'h-4 w-4', on: 'translate-x-4', icon: 11 },
2233
2284
  md: { track: 'h-6 w-11', knob: 'h-5 w-5', on: 'translate-x-5', icon: 13 },
@@ -2240,7 +2291,7 @@ const SIZES$6 = {
2240
2291
  * states.
2241
2292
  */
2242
2293
  class SdsToggleSwitch {
2243
- fieldId = `sds-toggle-switch-${nextId$a++}`;
2294
+ fieldId = `sds-toggle-switch-${nextId$g++}`;
2244
2295
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
2245
2296
  /** Accessible name used when no visible [label] is provided. */
2246
2297
  ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
@@ -2484,7 +2535,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
2484
2535
  }]
2485
2536
  }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], trueValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "trueValue", required: false }] }], falseValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "falseValue", required: false }] }], onIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "onIcon", required: false }] }], offIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "offIcon", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }] } });
2486
2537
 
2487
- let nextId$9 = 0;
2538
+ let nextId$f = 0;
2488
2539
  /**
2489
2540
  * Boolean checkbox. Implements ControlValueAccessor — use with reactive forms
2490
2541
  * or `[(ngModel)]`. Renders a styled box (brand-filled with a check when on)
@@ -2496,7 +2547,7 @@ let nextId$9 = 0;
2496
2547
  * <sds-checkbox label="Fusion automatique" [(ngModel)]="autoMerge" />
2497
2548
  */
2498
2549
  class SdsCheckbox {
2499
- id = `sds-checkbox-${nextId$9++}`;
2550
+ id = `sds-checkbox-${nextId$f++}`;
2500
2551
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
2501
2552
  /** Accessible name used when no visible [label] is provided. */
2502
2553
  ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
@@ -2630,7 +2681,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
2630
2681
  }]
2631
2682
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }] } });
2632
2683
 
2633
- let nextId$8 = 0;
2684
+ let nextId$e = 0;
2634
2685
  /**
2635
2686
  * Mounted radios by group name. A pick updates the rest of its group, because a native radio says
2636
2687
  * nothing when the browser unchecks it — without this a reactive form would leave the old choice drawn.
@@ -2656,7 +2707,7 @@ const groups = new Map();
2656
2707
  * <sds-radio name="size" value="large" label="Grand" [(ngModel)]="size" />
2657
2708
  */
2658
2709
  class SdsRadio {
2659
- id = `sds-radio-${nextId$8++}`;
2710
+ id = `sds-radio-${nextId$e++}`;
2660
2711
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
2661
2712
  /** Accessible name used when no visible [label] is provided. */
2662
2713
  ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
@@ -2957,66 +3008,3211 @@ class SdsBadge {
2957
3008
  class="inline-flex items-center gap-1.5 rounded-full border font-semibold uppercase tracking-wide"
2958
3009
  [class]="classes()"
2959
3010
  >
2960
- @if (dot()) {
2961
- <span class="h-1.5 w-1.5 rounded-full" [class]="dotClass()"></span>
3011
+ @if (dot()) {
3012
+ <span class="h-1.5 w-1.5 rounded-full" [class]="dotClass()"></span>
3013
+ }
3014
+ <ng-content />
3015
+ </span>
3016
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3017
+ }
3018
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsBadge, decorators: [{
3019
+ type: Component,
3020
+ args: [{
3021
+ selector: 'sds-badge',
3022
+ changeDetection: ChangeDetectionStrategy.OnPush,
3023
+ host: { class: 'inline-flex' },
3024
+ template: `
3025
+ <span
3026
+ class="inline-flex items-center gap-1.5 rounded-full border font-semibold uppercase tracking-wide"
3027
+ [class]="classes()"
3028
+ >
3029
+ @if (dot()) {
3030
+ <span class="h-1.5 w-1.5 rounded-full" [class]="dotClass()"></span>
3031
+ }
3032
+ <ng-content />
3033
+ </span>
3034
+ `,
3035
+ }]
3036
+ }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], dot: [{ type: i0.Input, args: [{ isSignal: true, alias: "dot", required: false }] }] } });
3037
+
3038
+ const VARIANTS = {
3039
+ elevated: 'bg-surface border border-border shadow-sm',
3040
+ outlined: 'bg-surface border border-border',
3041
+ subtle: 'bg-muted border border-border',
3042
+ };
3043
+ /**
3044
+ * Generic surface container. Set [interactive]="true" for hover affordance
3045
+ * (used by the dashboard action cards).
3046
+ */
3047
+ class SdsCard {
3048
+ variant = input('elevated', ...(ngDevMode ? [{ debugName: "variant" }] : []));
3049
+ /** Inner padding utility (override with e.g. "p-4"). */
3050
+ padding = input('p-5', ...(ngDevMode ? [{ debugName: "padding" }] : []));
3051
+ interactive = input(false, ...(ngDevMode ? [{ debugName: "interactive", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3052
+ hostClass = computed(() => {
3053
+ const parts = ['block rounded-card', VARIANTS[this.variant()], this.padding()];
3054
+ if (this.interactive()) {
3055
+ parts.push('cursor-pointer transition-all duration-150 hover:border-brand-300 hover:shadow-md');
3056
+ }
3057
+ return parts.join(' ');
3058
+ }, ...(ngDevMode ? [{ debugName: "hostClass" }] : []));
3059
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsCard, deps: [], target: i0.ɵɵFactoryTarget.Component });
3060
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "20.3.25", type: SdsCard, isStandalone: true, selector: "sds-card", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "hostClass()" } }, ngImport: i0, template: `<ng-content />`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3061
+ }
3062
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsCard, decorators: [{
3063
+ type: Component,
3064
+ args: [{
3065
+ selector: 'sds-card',
3066
+ changeDetection: ChangeDetectionStrategy.OnPush,
3067
+ host: { '[class]': 'hostClass()' },
3068
+ template: `<ng-content />`,
3069
+ }]
3070
+ }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }] } });
3071
+
3072
+ /** Every allergen in Annex II order — iterate it to render a declaration grid of 14. */
3073
+ const SDS_ALLERGENS = [
3074
+ 'CEREALS_GLUTEN',
3075
+ 'CRUSTACEANS',
3076
+ 'EGGS',
3077
+ 'FISH',
3078
+ 'PEANUTS',
3079
+ 'SOYBEANS',
3080
+ 'MILK',
3081
+ 'NUTS',
3082
+ 'CELERY',
3083
+ 'MUSTARD',
3084
+ 'SESAME',
3085
+ 'SULPHITES',
3086
+ 'LUPIN',
3087
+ 'MOLLUSCS',
3088
+ ];
3089
+ /** Registry icon for each allergen. */
3090
+ const SDS_ALLERGEN_ICONS = {
3091
+ CEREALS_GLUTEN: 'allergen-cereals-gluten',
3092
+ CRUSTACEANS: 'allergen-crustaceans',
3093
+ EGGS: 'allergen-eggs',
3094
+ FISH: 'allergen-fish',
3095
+ PEANUTS: 'allergen-peanuts',
3096
+ SOYBEANS: 'allergen-soybeans',
3097
+ MILK: 'allergen-milk',
3098
+ NUTS: 'allergen-nuts',
3099
+ CELERY: 'allergen-celery',
3100
+ MUSTARD: 'allergen-mustard',
3101
+ SESAME: 'allergen-sesame',
3102
+ SULPHITES: 'allergen-sulphites',
3103
+ LUPIN: 'allergen-lupin',
3104
+ MOLLUSCS: 'allergen-molluscs',
3105
+ };
3106
+ /**
3107
+ * English names, as a fallback and for the showcase. An app serving more than one language passes its
3108
+ * own translated name through [label] — the library never picks a language for you.
3109
+ */
3110
+ const SDS_ALLERGEN_LABELS_EN = {
3111
+ CEREALS_GLUTEN: 'Cereals containing gluten',
3112
+ CRUSTACEANS: 'Crustaceans',
3113
+ EGGS: 'Eggs',
3114
+ FISH: 'Fish',
3115
+ PEANUTS: 'Peanuts',
3116
+ SOYBEANS: 'Soybeans',
3117
+ MILK: 'Milk',
3118
+ NUTS: 'Nuts',
3119
+ CELERY: 'Celery',
3120
+ MUSTARD: 'Mustard',
3121
+ SESAME: 'Sesame',
3122
+ SULPHITES: 'Sulphites',
3123
+ LUPIN: 'Lupin',
3124
+ MOLLUSCS: 'Molluscs',
3125
+ };
3126
+ /** Accepts the enum spelling, and tolerates `milk` / `cereals-gluten` so a hand-typed value still resolves. */
3127
+ function normalize(value) {
3128
+ if (!value)
3129
+ return null;
3130
+ const key = String(value).trim().toUpperCase().replace(/-/g, '_');
3131
+ return key in SDS_ALLERGEN_ICONS ? key : null;
3132
+ }
3133
+ /**
3134
+ * The pictogram for one EU allergen, taking the API enum value directly. With a [label] it is an image
3135
+ * with that accessible name (role="img"); without one it is decorative (aria-hidden), for when visible
3136
+ * text beside it already names the allergen.
3137
+ *
3138
+ * @example
3139
+ * <sds-allergen-icon allergen="MILK" label="Milk" />
3140
+ * <sds-allergen-icon [allergen]="a" [size]="16" /> <!-- decorative: text next to it names it -->
3141
+ */
3142
+ class SdsAllergenIcon {
3143
+ /** The allergen, as the API enum value (`MILK`, `CEREALS_GLUTEN`, …). */
3144
+ allergen = input.required(...(ngDevMode ? [{ debugName: "allergen" }] : []));
3145
+ /** Accessible name. Empty (default) = decorative, hidden from assistive tech. */
3146
+ label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
3147
+ /** Also show the [label] as a native tooltip on hover. */
3148
+ showTitle = input(false, ...(ngDevMode ? [{ debugName: "showTitle", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3149
+ /** Square size: a pixel number, a numeric string, or a keyword (sm·md·lg·xl). */
3150
+ size = input(20, ...(ngDevMode ? [{ debugName: "size" }] : []));
3151
+ /** Stroke width of the line icon. */
3152
+ strokeWidth = input(2, ...(ngDevMode ? [{ debugName: "strokeWidth" }] : []));
3153
+ iconName = computed(() => {
3154
+ const key = normalize(this.allergen());
3155
+ return key ? SDS_ALLERGEN_ICONS[key] : null;
3156
+ }, ...(ngDevMode ? [{ debugName: "iconName" }] : []));
3157
+ constructor() {
3158
+ if (isDevMode()) {
3159
+ effect(() => {
3160
+ if (!this.iconName()) {
3161
+ console.warn(`sds-allergen-icon: unknown allergen "${this.allergen()}".`);
3162
+ }
3163
+ });
3164
+ }
3165
+ }
3166
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsAllergenIcon, deps: [], target: i0.ɵɵFactoryTarget.Component });
3167
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsAllergenIcon, isStandalone: true, selector: "sds-allergen-icon", inputs: { allergen: { classPropertyName: "allergen", publicName: "allergen", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, showTitle: { classPropertyName: "showTitle", publicName: "showTitle", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, strokeWidth: { classPropertyName: "strokeWidth", publicName: "strokeWidth", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.role": "label() ? \"img\" : null", "attr.aria-label": "label() || null", "attr.aria-hidden": "label() ? null : \"true\"", "attr.title": "showTitle() && label() ? label() : null" }, classAttribute: "inline-flex shrink-0" }, ngImport: i0, template: `
3168
+ @if (iconName(); as name) {
3169
+ <sds-icon [name]="name" [size]="size()" [strokeWidth]="strokeWidth()" />
3170
+ }
3171
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3172
+ }
3173
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsAllergenIcon, decorators: [{
3174
+ type: Component,
3175
+ args: [{
3176
+ selector: 'sds-allergen-icon',
3177
+ changeDetection: ChangeDetectionStrategy.OnPush,
3178
+ imports: [SdsIcon],
3179
+ host: {
3180
+ class: 'inline-flex shrink-0',
3181
+ '[attr.role]': 'label() ? "img" : null',
3182
+ '[attr.aria-label]': 'label() || null',
3183
+ '[attr.aria-hidden]': 'label() ? null : "true"',
3184
+ '[attr.title]': 'showTitle() && label() ? label() : null',
3185
+ },
3186
+ template: `
3187
+ @if (iconName(); as name) {
3188
+ <sds-icon [name]="name" [size]="size()" [strokeWidth]="strokeWidth()" />
3189
+ }
3190
+ `,
3191
+ }]
3192
+ }], ctorParameters: () => [], propDecorators: { allergen: [{ type: i0.Input, args: [{ isSignal: true, alias: "allergen", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], showTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "showTitle", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], strokeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "strokeWidth", required: false }] }] } });
3193
+
3194
+ /** Activation order: none → contains → may-contain → none. */
3195
+ const ORDER = ['none', 'contains', 'may-contain'];
3196
+ let nextId$d = 0;
3197
+ /**
3198
+ * A chip that cycles through three states — `none`, `contains`, `may-contain` — built for the 14-tile
3199
+ * allergen declaration grid, but generic (any "no / yes / maybe" declaration).
3200
+ *
3201
+ * **The state never relies on colour alone.** Each state has its own border (thin solid · thick solid
3202
+ * and filled · thick dashed), its own mark (none · a check · a question mark), a visible state caption
3203
+ * (turn off with `showState`), and the state text is part of the button's accessible name.
3204
+ *
3205
+ * Interaction: click, Enter or Space moves to the next state; Shift+Enter / Shift+Space moves back;
3206
+ * Delete or Backspace resets to `none`. A polite live region announces the new state after a change.
3207
+ * The value is a two-way `model` — bind `[(value)]`.
3208
+ *
3209
+ * All visible and spoken text comes from inputs with English defaults; pass translated labels.
3210
+ *
3211
+ * @example
3212
+ * <sds-tri-state-chip label="Milk" icon="allergen-milk" [(value)]="state.MILK"
3213
+ * containsLabel="Contains" mayContainLabel="May contain" noneLabel="Not present" />
3214
+ */
3215
+ class SdsTriStateChip {
3216
+ chipId = `sds-tri-state-chip-${nextId$d++}`;
3217
+ /** Current state. Two-way: `[(value)]`. */
3218
+ value = model('none', ...(ngDevMode ? [{ debugName: "value" }] : []));
3219
+ /** Visible name (e.g. the allergen). Required — the chip has no other accessible name. */
3220
+ label = input.required(...(ngDevMode ? [{ debugName: "label" }] : []));
3221
+ /** Optional leading registry icon, e.g. an `allergen-*` pictogram. */
3222
+ icon = input(null, ...(ngDevMode ? [{ debugName: "icon" }] : []));
3223
+ size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
3224
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3225
+ /** Show the state as a small visible caption under the label. */
3226
+ showState = input(true, ...(ngDevMode ? [{ debugName: "showState", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3227
+ /** State text for `none` (visible caption and spoken). */
3228
+ noneLabel = input('Not present', ...(ngDevMode ? [{ debugName: "noneLabel" }] : []));
3229
+ /** State text for `contains`. */
3230
+ containsLabel = input('Contains', ...(ngDevMode ? [{ debugName: "containsLabel" }] : []));
3231
+ /** State text for `may-contain`. */
3232
+ mayContainLabel = input('May contain', ...(ngDevMode ? [{ debugName: "mayContainLabel" }] : []));
3233
+ /**
3234
+ * Optional usage hint read after the name, e.g. "Press to change". Off by default: a button that
3235
+ * announces its state and changes on activation is self-explanatory to most users.
3236
+ */
3237
+ hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
3238
+ /** Last user-driven change, spoken by the live region. Programmatic writes stay silent. */
3239
+ announcement = signal('', ...(ngDevMode ? [{ debugName: "announcement" }] : []));
3240
+ stateText = computed(() => this.textFor(this.value()), ...(ngDevMode ? [{ debugName: "stateText" }] : []));
3241
+ chipClass = computed(() => {
3242
+ const sm = this.size() === 'sm';
3243
+ const base = 'relative flex w-full min-w-0 cursor-pointer select-none items-center rounded-control border-2 ' +
3244
+ 'text-left transition-colors focus-visible:outline-none focus-visible:ring-2 ' +
3245
+ 'focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface ' +
3246
+ 'disabled:cursor-not-allowed disabled:opacity-60 ' +
3247
+ (sm ? 'min-h-9 gap-2 px-2 py-1' : 'min-h-12 gap-2.5 px-3 py-2');
3248
+ switch (this.value()) {
3249
+ case 'contains':
3250
+ // Filled + solid thick border + check. In dark mode a monochrome brand-600 is a dim grey, so the
3251
+ // fill inverts to ink/surface (the same rule as sds-toggle-switch and sds-button primary).
3252
+ return (`${base} border-brand-600 bg-brand-600 text-white ` +
3253
+ 'dark:border-ink dark:bg-ink dark:text-surface hover:bg-brand-700 dark:hover:bg-ink-soft');
3254
+ case 'may-contain':
3255
+ // Unfilled + DASHED thick border + question mark.
3256
+ return (`${base} border-dashed border-brand-600 bg-brand-50 text-ink ` +
3257
+ 'dark:border-ink-soft dark:bg-muted hover:bg-brand-100 dark:hover:bg-surface');
3258
+ default:
3259
+ return `${base} border-border bg-surface text-ink-soft hover:border-ink-faint hover:text-ink`;
3260
+ }
3261
+ }, ...(ngDevMode ? [{ debugName: "chipClass" }] : []));
3262
+ captionClass = computed(() => {
3263
+ switch (this.value()) {
3264
+ case 'contains':
3265
+ return 'text-white/85 dark:text-surface/85';
3266
+ case 'may-contain':
3267
+ return 'text-ink-soft';
3268
+ default:
3269
+ return 'text-ink-faint';
3270
+ }
3271
+ }, ...(ngDevMode ? [{ debugName: "captionClass" }] : []));
3272
+ onClick(event) {
3273
+ if (this.disabled())
3274
+ return;
3275
+ // A real Shift+click steps back too; keyboard activation is handled in onKeydown.
3276
+ this.step(event.shiftKey ? -1 : 1);
3277
+ }
3278
+ onKeydown(event) {
3279
+ if (this.disabled())
3280
+ return;
3281
+ if ((event.key === 'Enter' || event.key === ' ') && event.shiftKey) {
3282
+ // Handle it here so the native button click (always "forward") does not also fire.
3283
+ event.preventDefault();
3284
+ this.step(-1);
3285
+ }
3286
+ else if (event.key === 'Delete' || event.key === 'Backspace') {
3287
+ event.preventDefault();
3288
+ this.set('none');
3289
+ }
3290
+ }
3291
+ step(delta) {
3292
+ const i = ORDER.indexOf(this.value());
3293
+ const next = ORDER[(Math.max(0, i) + delta + ORDER.length) % ORDER.length];
3294
+ this.set(next);
3295
+ }
3296
+ set(next) {
3297
+ if (next === this.value())
3298
+ return;
3299
+ this.value.set(next);
3300
+ this.announcement.set(`${this.label()}: ${this.textFor(next)}`);
3301
+ }
3302
+ textFor(state) {
3303
+ switch (state) {
3304
+ case 'contains':
3305
+ return this.containsLabel();
3306
+ case 'may-contain':
3307
+ return this.mayContainLabel();
3308
+ default:
3309
+ return this.noneLabel();
3310
+ }
3311
+ }
3312
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsTriStateChip, deps: [], target: i0.ɵɵFactoryTarget.Component });
3313
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsTriStateChip, isStandalone: true, selector: "sds-tri-state-chip", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, showState: { classPropertyName: "showState", publicName: "showState", isSignal: true, isRequired: false, transformFunction: null }, noneLabel: { classPropertyName: "noneLabel", publicName: "noneLabel", isSignal: true, isRequired: false, transformFunction: null }, containsLabel: { classPropertyName: "containsLabel", publicName: "containsLabel", isSignal: true, isRequired: false, transformFunction: null }, mayContainLabel: { classPropertyName: "mayContainLabel", publicName: "mayContainLabel", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange" }, host: { classAttribute: "inline-flex min-w-0" }, ngImport: i0, template: `
3314
+ <button
3315
+ type="button"
3316
+ [id]="chipId"
3317
+ [class]="chipClass()"
3318
+ [disabled]="disabled()"
3319
+ [attr.data-state]="value()"
3320
+ [attr.aria-describedby]="hint() ? chipId + '-hint' : null"
3321
+ (click)="onClick($event)"
3322
+ (keydown)="onKeydown($event)"
3323
+ >
3324
+ @if (icon()) {
3325
+ <sds-icon [name]="icon()!" [size]="size() === 'sm' ? 18 : 22" class="shrink-0" />
3326
+ }
3327
+
3328
+ <span class="flex min-w-0 flex-1 flex-col text-left leading-tight">
3329
+ <!-- Up to two lines, so "Cereals containing gluten" stays readable in a narrow grid cell. -->
3330
+ <span
3331
+ class="line-clamp-2 break-words font-semibold"
3332
+ [class]="size() === 'sm' ? 'text-xs' : 'text-sm'"
3333
+ >{{ label() }}</span
3334
+ >
3335
+ @if (showState()) {
3336
+ <span
3337
+ class="truncate text-[11px] font-medium"
3338
+ [class]="captionClass()"
3339
+ aria-hidden="true"
3340
+ >{{ stateText() }}</span
3341
+ >
3342
+ }
3343
+ </span>
3344
+ <!-- The state is part of the accessible name ("Milk, may contain"), visible caption or not. -->
3345
+ <span class="sr-only">, {{ stateText() }}</span>
3346
+
3347
+ <!-- Non-colour mark: nothing / a check / a question mark. The slot keeps its width in every
3348
+ state so the chip never shifts when cycled. -->
3349
+ <span
3350
+ class="grid shrink-0 place-items-center"
3351
+ [class]="size() === 'sm' ? 'h-4 w-4' : 'h-5 w-5'"
3352
+ aria-hidden="true"
3353
+ >
3354
+ @switch (value()) {
3355
+ @case ('contains') {
3356
+ <sds-icon name="check" [size]="size() === 'sm' ? 14 : 18" [strokeWidth]="3" />
3357
+ }
3358
+ @case ('may-contain') {
3359
+ <sds-icon name="circle-help" [size]="size() === 'sm' ? 15 : 19" />
3360
+ }
3361
+ }
3362
+ </span>
3363
+ </button>
3364
+
3365
+ @if (hint()) {
3366
+ <span [id]="chipId + '-hint'" class="sr-only">{{ hint() }}</span>
3367
+ }
3368
+ <span class="sr-only" aria-live="polite" aria-atomic="true">{{ announcement() }}</span>
3369
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3370
+ }
3371
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsTriStateChip, decorators: [{
3372
+ type: Component,
3373
+ args: [{
3374
+ selector: 'sds-tri-state-chip',
3375
+ changeDetection: ChangeDetectionStrategy.OnPush,
3376
+ imports: [SdsIcon],
3377
+ host: { class: 'inline-flex min-w-0' },
3378
+ template: `
3379
+ <button
3380
+ type="button"
3381
+ [id]="chipId"
3382
+ [class]="chipClass()"
3383
+ [disabled]="disabled()"
3384
+ [attr.data-state]="value()"
3385
+ [attr.aria-describedby]="hint() ? chipId + '-hint' : null"
3386
+ (click)="onClick($event)"
3387
+ (keydown)="onKeydown($event)"
3388
+ >
3389
+ @if (icon()) {
3390
+ <sds-icon [name]="icon()!" [size]="size() === 'sm' ? 18 : 22" class="shrink-0" />
3391
+ }
3392
+
3393
+ <span class="flex min-w-0 flex-1 flex-col text-left leading-tight">
3394
+ <!-- Up to two lines, so "Cereals containing gluten" stays readable in a narrow grid cell. -->
3395
+ <span
3396
+ class="line-clamp-2 break-words font-semibold"
3397
+ [class]="size() === 'sm' ? 'text-xs' : 'text-sm'"
3398
+ >{{ label() }}</span
3399
+ >
3400
+ @if (showState()) {
3401
+ <span
3402
+ class="truncate text-[11px] font-medium"
3403
+ [class]="captionClass()"
3404
+ aria-hidden="true"
3405
+ >{{ stateText() }}</span
3406
+ >
3407
+ }
3408
+ </span>
3409
+ <!-- The state is part of the accessible name ("Milk, may contain"), visible caption or not. -->
3410
+ <span class="sr-only">, {{ stateText() }}</span>
3411
+
3412
+ <!-- Non-colour mark: nothing / a check / a question mark. The slot keeps its width in every
3413
+ state so the chip never shifts when cycled. -->
3414
+ <span
3415
+ class="grid shrink-0 place-items-center"
3416
+ [class]="size() === 'sm' ? 'h-4 w-4' : 'h-5 w-5'"
3417
+ aria-hidden="true"
3418
+ >
3419
+ @switch (value()) {
3420
+ @case ('contains') {
3421
+ <sds-icon name="check" [size]="size() === 'sm' ? 14 : 18" [strokeWidth]="3" />
3422
+ }
3423
+ @case ('may-contain') {
3424
+ <sds-icon name="circle-help" [size]="size() === 'sm' ? 15 : 19" />
3425
+ }
3426
+ }
3427
+ </span>
3428
+ </button>
3429
+
3430
+ @if (hint()) {
3431
+ <span [id]="chipId + '-hint'" class="sr-only">{{ hint() }}</span>
3432
+ }
3433
+ <span class="sr-only" aria-live="polite" aria-atomic="true">{{ announcement() }}</span>
3434
+ `,
3435
+ }]
3436
+ }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], showState: [{ type: i0.Input, args: [{ isSignal: true, alias: "showState", required: false }] }], noneLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "noneLabel", required: false }] }], containsLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "containsLabel", required: false }] }], mayContainLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "mayContainLabel", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }] } });
3437
+
3438
+ let nextId$c = 0;
3439
+ /**
3440
+ * Segmented control: a small set of mutually exclusive options shown side by side (e.g. "Pick exactly /
3441
+ * at least / up to"). Radio-group semantics: `role="radiogroup"` with `role="radio"` segments, one tab
3442
+ * stop, and the arrow keys (with Home/End) move AND select, like native radios. Disabled segments are
3443
+ * skipped.
3444
+ *
3445
+ * The value is a two-way `model` — bind `[(value)]`. Give the group a name with [ariaLabel] (or
3446
+ * [ariaLabelledby]); with [iconOnly] each segment keeps its label as its accessible name.
3447
+ *
3448
+ * @example
3449
+ * <sds-segmented ariaLabel="Rule" [options]="[{ value: 'exactly', label: 'Exactly' }, { value: 'upTo', label: 'Up to' }]"
3450
+ * [(value)]="rule" />
3451
+ */
3452
+ class SdsSegmented {
3453
+ host = inject(ElementRef);
3454
+ baseId = `sds-segmented-${nextId$c++}`;
3455
+ /** Options: `{ value, label, icon?, disabled? }` objects, or bare strings/numbers. */
3456
+ options = input([], ...(ngDevMode ? [{ debugName: "options" }] : []));
3457
+ /** Selected value. Two-way: `[(value)]`. */
3458
+ value = model(null, ...(ngDevMode ? [{ debugName: "value" }] : []));
3459
+ size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
3460
+ /** Accessible name of the group. */
3461
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
3462
+ /** Id of an element that names the group (alternative to [ariaLabel]). */
3463
+ ariaLabelledby = input('', ...(ngDevMode ? [{ debugName: "ariaLabelledby" }] : []));
3464
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3465
+ /** Stretch to the container width, segments sharing it equally. */
3466
+ fullWidth = input(false, ...(ngDevMode ? [{ debugName: "fullWidth", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3467
+ /** Show icons only; labels stay as accessible names and tooltips. */
3468
+ iconOnly = input(false, ...(ngDevMode ? [{ debugName: "iconOnly", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3469
+ segments = computed(() => this.options().map((o) => o !== null && typeof o === 'object'
3470
+ ? { value: o.value, label: o.label, icon: o.icon ?? null, disabled: !!o.disabled }
3471
+ : { value: o, label: String(o), icon: null, disabled: false }), ...(ngDevMode ? [{ debugName: "segments" }] : []));
3472
+ selectedIndex = computed(() => this.segments().findIndex((s) => s.value === this.value()), ...(ngDevMode ? [{ debugName: "selectedIndex" }] : []));
3473
+ /** Roving tab stop: the selected segment, else the first enabled one. */
3474
+ tabStop = computed(() => {
3475
+ const sel = this.selectedIndex();
3476
+ if (sel >= 0 && !this.segments()[sel].disabled)
3477
+ return sel;
3478
+ return this.segments().findIndex((s) => !s.disabled);
3479
+ }, ...(ngDevMode ? [{ debugName: "tabStop" }] : []));
3480
+ isSelected(seg) {
3481
+ return seg.value === this.value();
3482
+ }
3483
+ segmentId(i) {
3484
+ return `${this.baseId}-${i}`;
3485
+ }
3486
+ segmentClass(seg) {
3487
+ const sm = this.size() === 'sm';
3488
+ const base = 'inline-flex min-w-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-[calc(var(--radius-control)-2px)] ' +
3489
+ 'font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ' +
3490
+ 'disabled:cursor-not-allowed disabled:opacity-50 ' +
3491
+ (sm ? 'h-7 px-2.5 text-xs' : 'h-8 px-3.5 text-sm') +
3492
+ (this.fullWidth() ? ' flex-1' : '') +
3493
+ (this.iconOnly() ? (sm ? ' w-8 px-0' : ' w-9 px-0') : '');
3494
+ // Selected: raised surface, ring and bold text — more than a tint, so it reads in greyscale too.
3495
+ return this.isSelected(seg)
3496
+ ? `${base} bg-surface font-semibold text-ink shadow-sm ring-1 ring-border dark:bg-border`
3497
+ : `${base} cursor-pointer text-ink-soft hover:bg-surface/60 hover:text-ink`;
3498
+ }
3499
+ choose(i) {
3500
+ const seg = this.segments()[i];
3501
+ if (!seg || seg.disabled || this.disabled())
3502
+ return;
3503
+ this.value.set(seg.value);
3504
+ }
3505
+ onKeydown(event) {
3506
+ if (this.disabled())
3507
+ return;
3508
+ const segs = this.segments();
3509
+ if (!segs.length)
3510
+ return;
3511
+ const current = Number(event.target.dataset['index'] ?? this.tabStop());
3512
+ let next = null;
3513
+ switch (event.key) {
3514
+ case 'ArrowRight':
3515
+ case 'ArrowDown':
3516
+ next = this.step(current, 1);
3517
+ break;
3518
+ case 'ArrowLeft':
3519
+ case 'ArrowUp':
3520
+ next = this.step(current, -1);
3521
+ break;
3522
+ case 'Home':
3523
+ next = segs.findIndex((s) => !s.disabled);
3524
+ break;
3525
+ case 'End':
3526
+ for (let i = segs.length - 1; i >= 0; i--) {
3527
+ if (!segs[i].disabled) {
3528
+ next = i;
3529
+ break;
3530
+ }
3531
+ }
3532
+ break;
3533
+ default:
3534
+ return;
3535
+ }
3536
+ event.preventDefault();
3537
+ if (next === null || next < 0)
3538
+ return;
3539
+ this.choose(next);
3540
+ this.host.nativeElement.querySelector(`#${this.segmentId(next)}`)?.focus();
3541
+ }
3542
+ step(from, delta) {
3543
+ const segs = this.segments();
3544
+ let i = from;
3545
+ for (let n = 0; n < segs.length; n++) {
3546
+ i = (i + delta + segs.length) % segs.length;
3547
+ if (!segs[i].disabled)
3548
+ return i;
3549
+ }
3550
+ return null;
3551
+ }
3552
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSegmented, deps: [], target: i0.ɵɵFactoryTarget.Component });
3553
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsSegmented, isStandalone: true, selector: "sds-segmented", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "ariaLabelledby", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, fullWidth: { classPropertyName: "fullWidth", publicName: "fullWidth", isSignal: true, isRequired: false, transformFunction: null }, iconOnly: { classPropertyName: "iconOnly", publicName: "iconOnly", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange" }, host: { properties: { "class.w-full": "fullWidth()" }, classAttribute: "inline-flex max-w-full" }, ngImport: i0, template: `
3554
+ <div
3555
+ role="radiogroup"
3556
+ [attr.aria-label]="ariaLabel() || null"
3557
+ [attr.aria-labelledby]="ariaLabelledby() || null"
3558
+ [attr.aria-disabled]="disabled() ? 'true' : null"
3559
+ class="flex max-w-full items-stretch gap-0.5 overflow-x-auto rounded-control border border-border bg-muted p-0.5"
3560
+ [class.w-full]="fullWidth()"
3561
+ (keydown)="onKeydown($event)"
3562
+ >
3563
+ @for (seg of segments(); track $index; let i = $index) {
3564
+ <button
3565
+ type="button"
3566
+ role="radio"
3567
+ [id]="segmentId(i)"
3568
+ [attr.aria-checked]="isSelected(seg)"
3569
+ [attr.aria-label]="iconOnly() ? seg.label : null"
3570
+ [attr.title]="iconOnly() ? seg.label : null"
3571
+ [attr.tabindex]="i === tabStop() ? 0 : -1"
3572
+ [attr.data-index]="i"
3573
+ [disabled]="disabled() || seg.disabled"
3574
+ [class]="segmentClass(seg)"
3575
+ (click)="choose(i)"
3576
+ >
3577
+ @if (seg.icon) {
3578
+ <sds-icon [name]="seg.icon" [size]="size() === 'sm' ? 15 : 17" class="shrink-0" />
3579
+ }
3580
+ @if (!iconOnly()) {
3581
+ <span class="truncate">{{ seg.label }}</span>
3582
+ }
3583
+ </button>
3584
+ }
3585
+ </div>
3586
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3587
+ }
3588
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSegmented, decorators: [{
3589
+ type: Component,
3590
+ args: [{
3591
+ selector: 'sds-segmented',
3592
+ changeDetection: ChangeDetectionStrategy.OnPush,
3593
+ imports: [SdsIcon],
3594
+ host: { class: 'inline-flex max-w-full', '[class.w-full]': 'fullWidth()' },
3595
+ template: `
3596
+ <div
3597
+ role="radiogroup"
3598
+ [attr.aria-label]="ariaLabel() || null"
3599
+ [attr.aria-labelledby]="ariaLabelledby() || null"
3600
+ [attr.aria-disabled]="disabled() ? 'true' : null"
3601
+ class="flex max-w-full items-stretch gap-0.5 overflow-x-auto rounded-control border border-border bg-muted p-0.5"
3602
+ [class.w-full]="fullWidth()"
3603
+ (keydown)="onKeydown($event)"
3604
+ >
3605
+ @for (seg of segments(); track $index; let i = $index) {
3606
+ <button
3607
+ type="button"
3608
+ role="radio"
3609
+ [id]="segmentId(i)"
3610
+ [attr.aria-checked]="isSelected(seg)"
3611
+ [attr.aria-label]="iconOnly() ? seg.label : null"
3612
+ [attr.title]="iconOnly() ? seg.label : null"
3613
+ [attr.tabindex]="i === tabStop() ? 0 : -1"
3614
+ [attr.data-index]="i"
3615
+ [disabled]="disabled() || seg.disabled"
3616
+ [class]="segmentClass(seg)"
3617
+ (click)="choose(i)"
3618
+ >
3619
+ @if (seg.icon) {
3620
+ <sds-icon [name]="seg.icon" [size]="size() === 'sm' ? 15 : 17" class="shrink-0" />
3621
+ }
3622
+ @if (!iconOnly()) {
3623
+ <span class="truncate">{{ seg.label }}</span>
3624
+ }
3625
+ </button>
3626
+ }
3627
+ </div>
3628
+ `,
3629
+ }]
3630
+ }], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], ariaLabelledby: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabelledby", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], fullWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "fullWidth", required: false }] }], iconOnly: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconOnly", required: false }] }] } });
3631
+
3632
+ /**
3633
+ * Each status has its own glyph SHAPE (circle-check, triangle, octagon, circle-i) as well as its own
3634
+ * tint, so the row still reads in greyscale or for a colour-blind reader. `label` is the English
3635
+ * default of the spoken status; override it with [statusLabel].
3636
+ */
3637
+ const STATUS = {
3638
+ done: { icon: 'check-circle', tile: 'bg-accent-green-soft text-accent-green', label: 'Done' },
3639
+ warning: { icon: 'triangle-alert', tile: 'bg-accent-amber-soft text-accent-amber', label: 'Warning' },
3640
+ blocker: { icon: 'octagon-x', tile: 'bg-accent-red-soft text-accent-red', label: 'Blocking' },
3641
+ info: { icon: 'info', tile: 'bg-muted text-ink-soft', label: 'Info' },
3642
+ };
3643
+ /**
3644
+ * One line of a readiness checklist or health report: a status glyph, a title, an optional
3645
+ * description, an optional count pill and an action (a projected `[sdsChecklistAction]` element, or
3646
+ * the built-in button from [actionLabel] that emits `(action)`).
3647
+ *
3648
+ * The status is announced as text before the title ("Blocking: 3 items have no price"), never by
3649
+ * colour alone. Put rows in a list for screen readers: `<ul role="list">` with each row wrapped in
3650
+ * an `<li>`, or give the container `role="list"` and each row `role="listitem"`.
3651
+ *
3652
+ * @example
3653
+ * <sds-checklist-row status="blocker" title="3 items have no price" description="Customers can't order them."
3654
+ * [count]="3" actionLabel="Fix" (action)="openFilter('no-price')" />
3655
+ */
3656
+ class SdsChecklistRow {
3657
+ status = input('info', ...(ngDevMode ? [{ debugName: "status" }] : []));
3658
+ title = input.required(...(ngDevMode ? [{ debugName: "title" }] : []));
3659
+ description = input('', ...(ngDevMode ? [{ debugName: "description" }] : []));
3660
+ /** Optional number of affected things, shown as a pill after the title. Null hides it. */
3661
+ count = input(null, ...(ngDevMode ? [{ debugName: "count" }] : []));
3662
+ /**
3663
+ * Accessible text for the count pill; `{count}` is replaced. Default `'{count} items'` — pass a
3664
+ * translated one.
3665
+ */
3666
+ countLabel = input('{count} items', ...(ngDevMode ? [{ debugName: "countLabel" }] : []));
3667
+ /** Spoken status prefix. Defaults per status: Done · Warning · Blocking · Info (English). */
3668
+ statusLabel = input('', ...(ngDevMode ? [{ debugName: "statusLabel" }] : []));
3669
+ /** Label of the built-in action button. Empty hides it (project `[sdsChecklistAction]` instead). */
3670
+ actionLabel = input('', ...(ngDevMode ? [{ debugName: "actionLabel" }] : []));
3671
+ /**
3672
+ * Full accessible name for the action button when its visible label is terse ("Fix") — e.g.
3673
+ * "Fix: 3 items have no price".
3674
+ */
3675
+ actionAriaLabel = input('', ...(ngDevMode ? [{ debugName: "actionAriaLabel" }] : []));
3676
+ /** Strike the title through when the row is done. */
3677
+ strikeDone = input(false, ...(ngDevMode ? [{ debugName: "strikeDone", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3678
+ /** Emitted when the built-in action button is pressed. */
3679
+ action = output();
3680
+ style = computed(() => STATUS[this.status()] ?? STATUS.info, ...(ngDevMode ? [{ debugName: "style" }] : []));
3681
+ resolvedStatusLabel = computed(() => this.statusLabel() || this.style().label, ...(ngDevMode ? [{ debugName: "resolvedStatusLabel" }] : []));
3682
+ countText = computed(() => this.countLabel().replace('{count}', String(this.count() ?? '')), ...(ngDevMode ? [{ debugName: "countText" }] : []));
3683
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsChecklistRow, deps: [], target: i0.ɵɵFactoryTarget.Component });
3684
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsChecklistRow, isStandalone: true, selector: "sds-checklist-row", inputs: { status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: true, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, count: { classPropertyName: "count", publicName: "count", isSignal: true, isRequired: false, transformFunction: null }, countLabel: { classPropertyName: "countLabel", publicName: "countLabel", isSignal: true, isRequired: false, transformFunction: null }, statusLabel: { classPropertyName: "statusLabel", publicName: "statusLabel", isSignal: true, isRequired: false, transformFunction: null }, actionLabel: { classPropertyName: "actionLabel", publicName: "actionLabel", isSignal: true, isRequired: false, transformFunction: null }, actionAriaLabel: { classPropertyName: "actionAriaLabel", publicName: "actionAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, strikeDone: { classPropertyName: "strikeDone", publicName: "strikeDone", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { action: "action" }, host: { classAttribute: "block" }, ngImport: i0, template: `
3685
+ <div
3686
+ class="flex items-start gap-3 rounded-control px-3 py-3"
3687
+ [class.opacity-80]="status() === 'done'"
3688
+ [attr.data-status]="status()"
3689
+ >
3690
+ <span
3691
+ class="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full"
3692
+ [class]="style().tile"
3693
+ aria-hidden="true"
3694
+ >
3695
+ <sds-icon [name]="style().icon" [size]="18" />
3696
+ </span>
3697
+
3698
+ <div class="min-w-0 flex-1">
3699
+ <p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-semibold text-ink">
3700
+ <span class="sr-only">{{ resolvedStatusLabel() }}:</span>
3701
+ <span [class.line-through]="status() === 'done' && strikeDone()">{{ title() }}</span>
3702
+ @if (count() !== null) {
3703
+ <span
3704
+ class="inline-flex min-w-6 items-center justify-center rounded-full border border-border bg-muted px-1.5 text-xs font-semibold tabular-nums text-ink-soft"
3705
+ aria-hidden="true"
3706
+ >{{ count() }}</span
3707
+ >
3708
+ <span class="sr-only">({{ countText() }})</span>
3709
+ }
3710
+ </p>
3711
+ @if (description()) {
3712
+ <p class="mt-0.5 text-sm text-ink-soft">{{ description() }}</p>
3713
+ }
3714
+ <ng-content />
3715
+ </div>
3716
+
3717
+ <div class="flex shrink-0 items-center gap-2 self-center">
3718
+ <ng-content select="[sdsChecklistAction]" />
3719
+ @if (actionLabel()) {
3720
+ <button
3721
+ type="button"
3722
+ class="inline-flex h-9 items-center gap-1.5 rounded-control border border-border bg-surface px-3 text-sm font-semibold text-ink transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
3723
+ [attr.aria-label]="actionAriaLabel() || null"
3724
+ (click)="action.emit()"
3725
+ >
3726
+ {{ actionLabel() }}
3727
+ <sds-icon name="chevron-right" [size]="16" class="text-ink-faint" />
3728
+ </button>
3729
+ }
3730
+ </div>
3731
+ </div>
3732
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3733
+ }
3734
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsChecklistRow, decorators: [{
3735
+ type: Component,
3736
+ args: [{
3737
+ selector: 'sds-checklist-row',
3738
+ changeDetection: ChangeDetectionStrategy.OnPush,
3739
+ imports: [SdsIcon],
3740
+ host: { class: 'block' },
3741
+ template: `
3742
+ <div
3743
+ class="flex items-start gap-3 rounded-control px-3 py-3"
3744
+ [class.opacity-80]="status() === 'done'"
3745
+ [attr.data-status]="status()"
3746
+ >
3747
+ <span
3748
+ class="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full"
3749
+ [class]="style().tile"
3750
+ aria-hidden="true"
3751
+ >
3752
+ <sds-icon [name]="style().icon" [size]="18" />
3753
+ </span>
3754
+
3755
+ <div class="min-w-0 flex-1">
3756
+ <p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-semibold text-ink">
3757
+ <span class="sr-only">{{ resolvedStatusLabel() }}:</span>
3758
+ <span [class.line-through]="status() === 'done' && strikeDone()">{{ title() }}</span>
3759
+ @if (count() !== null) {
3760
+ <span
3761
+ class="inline-flex min-w-6 items-center justify-center rounded-full border border-border bg-muted px-1.5 text-xs font-semibold tabular-nums text-ink-soft"
3762
+ aria-hidden="true"
3763
+ >{{ count() }}</span
3764
+ >
3765
+ <span class="sr-only">({{ countText() }})</span>
3766
+ }
3767
+ </p>
3768
+ @if (description()) {
3769
+ <p class="mt-0.5 text-sm text-ink-soft">{{ description() }}</p>
3770
+ }
3771
+ <ng-content />
3772
+ </div>
3773
+
3774
+ <div class="flex shrink-0 items-center gap-2 self-center">
3775
+ <ng-content select="[sdsChecklistAction]" />
3776
+ @if (actionLabel()) {
3777
+ <button
3778
+ type="button"
3779
+ class="inline-flex h-9 items-center gap-1.5 rounded-control border border-border bg-surface px-3 text-sm font-semibold text-ink transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
3780
+ [attr.aria-label]="actionAriaLabel() || null"
3781
+ (click)="action.emit()"
3782
+ >
3783
+ {{ actionLabel() }}
3784
+ <sds-icon name="chevron-right" [size]="16" class="text-ink-faint" />
3785
+ </button>
3786
+ }
3787
+ </div>
3788
+ </div>
3789
+ `,
3790
+ }]
3791
+ }], propDecorators: { status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: true }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], count: [{ type: i0.Input, args: [{ isSignal: true, alias: "count", required: false }] }], countLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "countLabel", required: false }] }], statusLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "statusLabel", required: false }] }], actionLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "actionLabel", required: false }] }], actionAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "actionAriaLabel", required: false }] }], strikeDone: [{ type: i0.Input, args: [{ isSignal: true, alias: "strikeDone", required: false }] }], action: [{ type: i0.Output, args: ["action"] }] } });
3792
+
3793
+ const TIERS = {
3794
+ good: { icon: 'check-circle', text: 'text-accent-green', stroke: 'stroke-success', fill: 'bg-success' },
3795
+ fair: { icon: 'triangle-alert', text: 'text-accent-amber', stroke: 'stroke-warning', fill: 'bg-warning' },
3796
+ poor: { icon: 'octagon-x', text: 'text-accent-red', stroke: 'stroke-danger', fill: 'bg-danger' },
3797
+ };
3798
+ const RING = {
3799
+ sm: { box: 48, stroke: 5, value: 'text-sm', max: 'text-[9px]' },
3800
+ md: { box: 72, stroke: 7, value: 'text-xl', max: 'text-[10px]' },
3801
+ lg: { box: 96, stroke: 9, value: 'text-3xl', max: 'text-xs' },
3802
+ };
3803
+ const BAR_H = { sm: 'h-1.5', md: 'h-2', lg: 'h-3' };
3804
+ /**
3805
+ * A 0–100 score as a ring or a bar, tinted by tier (poor · fair · good). The tier is also written out
3806
+ * in words with its own glyph, and the whole thing is a `role="meter"` with a spoken value text — so
3807
+ * the score is never conveyed by colour alone.
3808
+ *
3809
+ * @example
3810
+ * <sds-score [value]="64" label="Menu health" />
3811
+ * <sds-score [value]="health()" variant="bar" label="Menu health" goodLabel="Great" />
3812
+ */
3813
+ class SdsScore {
3814
+ /** The score, 0–100 (clamped; non-numbers read as 0). */
3815
+ value = input(0, ...(ngDevMode ? [{ debugName: "value", transform: numberAttribute }] : [{ transform: numberAttribute }]));
3816
+ variant = input('ring', ...(ngDevMode ? [{ debugName: "variant" }] : []));
3817
+ size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
3818
+ /** Visible caption and the meter's accessible name, e.g. "Menu health". */
3819
+ label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
3820
+ /** Show the tier in words (with its glyph). */
3821
+ showTier = input(true, ...(ngDevMode ? [{ debugName: "showTier", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3822
+ /** Lowest value that counts as `fair`. */
3823
+ fairFrom = input(50, ...(ngDevMode ? [{ debugName: "fairFrom", transform: numberAttribute }] : [{ transform: numberAttribute }]));
3824
+ /** Lowest value that counts as `good`. */
3825
+ goodFrom = input(80, ...(ngDevMode ? [{ debugName: "goodFrom", transform: numberAttribute }] : [{ transform: numberAttribute }]));
3826
+ poorLabel = input('Needs work', ...(ngDevMode ? [{ debugName: "poorLabel" }] : []));
3827
+ fairLabel = input('Fair', ...(ngDevMode ? [{ debugName: "fairLabel" }] : []));
3828
+ goodLabel = input('Good', ...(ngDevMode ? [{ debugName: "goodLabel" }] : []));
3829
+ /** Spoken value; tokens `{value}` and `{tier}`. */
3830
+ valueTextTemplate = input('{value} out of 100, {tier}', ...(ngDevMode ? [{ debugName: "valueTextTemplate" }] : []));
3831
+ clamped = computed(() => {
3832
+ const v = this.value();
3833
+ return Number.isFinite(v) ? Math.round(Math.min(100, Math.max(0, v))) : 0;
3834
+ }, ...(ngDevMode ? [{ debugName: "clamped" }] : []));
3835
+ tier = computed(() => {
3836
+ const v = this.clamped();
3837
+ if (v >= this.goodFrom())
3838
+ return 'good';
3839
+ if (v >= this.fairFrom())
3840
+ return 'fair';
3841
+ return 'poor';
3842
+ }, ...(ngDevMode ? [{ debugName: "tier" }] : []));
3843
+ tierStyle = computed(() => TIERS[this.tier()], ...(ngDevMode ? [{ debugName: "tierStyle" }] : []));
3844
+ tierText = computed(() => this.tier() === 'good' ? this.goodLabel() : this.tier() === 'fair' ? this.fairLabel() : this.poorLabel(), ...(ngDevMode ? [{ debugName: "tierText" }] : []));
3845
+ valueText = computed(() => this.valueTextTemplate()
3846
+ .replace('{value}', String(this.clamped()))
3847
+ .replace('{tier}', this.tierText()), ...(ngDevMode ? [{ debugName: "valueText" }] : []));
3848
+ ring = computed(() => RING[this.size()], ...(ngDevMode ? [{ debugName: "ring" }] : []));
3849
+ radius = computed(() => (this.ring().box - this.ring().stroke) / 2, ...(ngDevMode ? [{ debugName: "radius" }] : []));
3850
+ circumference = computed(() => 2 * Math.PI * this.radius(), ...(ngDevMode ? [{ debugName: "circumference" }] : []));
3851
+ dashOffset = computed(() => this.circumference() * (1 - this.clamped() / 100), ...(ngDevMode ? [{ debugName: "dashOffset" }] : []));
3852
+ barHeight = computed(() => BAR_H[this.size()], ...(ngDevMode ? [{ debugName: "barHeight" }] : []));
3853
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsScore, deps: [], target: i0.ɵɵFactoryTarget.Component });
3854
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsScore, isStandalone: true, selector: "sds-score", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, showTier: { classPropertyName: "showTier", publicName: "showTier", isSignal: true, isRequired: false, transformFunction: null }, fairFrom: { classPropertyName: "fairFrom", publicName: "fairFrom", isSignal: true, isRequired: false, transformFunction: null }, goodFrom: { classPropertyName: "goodFrom", publicName: "goodFrom", isSignal: true, isRequired: false, transformFunction: null }, poorLabel: { classPropertyName: "poorLabel", publicName: "poorLabel", isSignal: true, isRequired: false, transformFunction: null }, fairLabel: { classPropertyName: "fairLabel", publicName: "fairLabel", isSignal: true, isRequired: false, transformFunction: null }, goodLabel: { classPropertyName: "goodLabel", publicName: "goodLabel", isSignal: true, isRequired: false, transformFunction: null }, valueTextTemplate: { classPropertyName: "valueTextTemplate", publicName: "valueTextTemplate", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block" }, ngImport: i0, template: `
3855
+ <div
3856
+ role="meter"
3857
+ aria-valuemin="0"
3858
+ aria-valuemax="100"
3859
+ [attr.aria-valuenow]="clamped()"
3860
+ [attr.aria-valuetext]="valueText()"
3861
+ [attr.aria-label]="label() || null"
3862
+ [attr.data-tier]="tier()"
3863
+ >
3864
+ @if (variant() === 'ring') {
3865
+ <div class="flex items-center gap-3" aria-hidden="true">
3866
+ <div class="relative shrink-0" [style.width.px]="ring().box" [style.height.px]="ring().box">
3867
+ <svg [attr.viewBox]="'0 0 ' + ring().box + ' ' + ring().box" class="-rotate-90" width="100%" height="100%">
3868
+ <circle
3869
+ [attr.cx]="ring().box / 2"
3870
+ [attr.cy]="ring().box / 2"
3871
+ [attr.r]="radius()"
3872
+ fill="none"
3873
+ class="stroke-border"
3874
+ [attr.stroke-width]="ring().stroke"
3875
+ />
3876
+ <circle
3877
+ [attr.cx]="ring().box / 2"
3878
+ [attr.cy]="ring().box / 2"
3879
+ [attr.r]="radius()"
3880
+ fill="none"
3881
+ stroke-linecap="round"
3882
+ class="transition-[stroke-dashoffset] duration-500"
3883
+ [class]="tierStyle().stroke"
3884
+ [attr.stroke-width]="ring().stroke"
3885
+ [attr.stroke-dasharray]="circumference()"
3886
+ [attr.stroke-dashoffset]="dashOffset()"
3887
+ />
3888
+ </svg>
3889
+ <div class="absolute inset-0 flex flex-col items-center justify-center leading-none">
3890
+ <span class="font-extrabold tabular-nums text-ink" [class]="ring().value">{{ clamped() }}</span>
3891
+ @if (size() !== 'sm') {
3892
+ <span class="mt-0.5 font-medium text-ink-faint" [class]="ring().max">/100</span>
3893
+ }
3894
+ </div>
3895
+ </div>
3896
+ @if (label() || showTier()) {
3897
+ <div class="min-w-0">
3898
+ @if (label()) {
3899
+ <p class="truncate text-sm font-semibold text-ink">{{ label() }}</p>
3900
+ }
3901
+ @if (showTier()) {
3902
+ <p class="mt-0.5 flex items-center gap-1 text-sm font-medium" [class]="tierStyle().text">
3903
+ <sds-icon [name]="tierStyle().icon" [size]="15" />
3904
+ {{ tierText() }}
3905
+ </p>
3906
+ }
3907
+ </div>
3908
+ }
3909
+ </div>
3910
+ } @else {
3911
+ <div aria-hidden="true">
3912
+ <div class="mb-1.5 flex items-baseline justify-between gap-3">
3913
+ <span class="truncate text-sm font-semibold text-ink">{{ label() }}</span>
3914
+ <span class="flex shrink-0 items-center gap-1.5 text-sm">
3915
+ <span class="font-bold tabular-nums text-ink">{{ clamped() }}<span class="text-ink-faint">/100</span></span>
3916
+ @if (showTier()) {
3917
+ <span class="flex items-center gap-1 font-medium" [class]="tierStyle().text">
3918
+ <sds-icon [name]="tierStyle().icon" [size]="14" />
3919
+ {{ tierText() }}
3920
+ </span>
3921
+ }
3922
+ </span>
3923
+ </div>
3924
+ <div class="w-full overflow-hidden rounded-full bg-border" [class]="barHeight()">
3925
+ <div
3926
+ class="h-full rounded-full transition-[width] duration-500"
3927
+ [class]="tierStyle().fill"
3928
+ [style.width.%]="clamped()"
3929
+ ></div>
3930
+ </div>
3931
+ </div>
3932
+ }
3933
+ </div>
3934
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3935
+ }
3936
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsScore, decorators: [{
3937
+ type: Component,
3938
+ args: [{
3939
+ selector: 'sds-score',
3940
+ changeDetection: ChangeDetectionStrategy.OnPush,
3941
+ imports: [SdsIcon],
3942
+ host: { class: 'block' },
3943
+ template: `
3944
+ <div
3945
+ role="meter"
3946
+ aria-valuemin="0"
3947
+ aria-valuemax="100"
3948
+ [attr.aria-valuenow]="clamped()"
3949
+ [attr.aria-valuetext]="valueText()"
3950
+ [attr.aria-label]="label() || null"
3951
+ [attr.data-tier]="tier()"
3952
+ >
3953
+ @if (variant() === 'ring') {
3954
+ <div class="flex items-center gap-3" aria-hidden="true">
3955
+ <div class="relative shrink-0" [style.width.px]="ring().box" [style.height.px]="ring().box">
3956
+ <svg [attr.viewBox]="'0 0 ' + ring().box + ' ' + ring().box" class="-rotate-90" width="100%" height="100%">
3957
+ <circle
3958
+ [attr.cx]="ring().box / 2"
3959
+ [attr.cy]="ring().box / 2"
3960
+ [attr.r]="radius()"
3961
+ fill="none"
3962
+ class="stroke-border"
3963
+ [attr.stroke-width]="ring().stroke"
3964
+ />
3965
+ <circle
3966
+ [attr.cx]="ring().box / 2"
3967
+ [attr.cy]="ring().box / 2"
3968
+ [attr.r]="radius()"
3969
+ fill="none"
3970
+ stroke-linecap="round"
3971
+ class="transition-[stroke-dashoffset] duration-500"
3972
+ [class]="tierStyle().stroke"
3973
+ [attr.stroke-width]="ring().stroke"
3974
+ [attr.stroke-dasharray]="circumference()"
3975
+ [attr.stroke-dashoffset]="dashOffset()"
3976
+ />
3977
+ </svg>
3978
+ <div class="absolute inset-0 flex flex-col items-center justify-center leading-none">
3979
+ <span class="font-extrabold tabular-nums text-ink" [class]="ring().value">{{ clamped() }}</span>
3980
+ @if (size() !== 'sm') {
3981
+ <span class="mt-0.5 font-medium text-ink-faint" [class]="ring().max">/100</span>
3982
+ }
3983
+ </div>
3984
+ </div>
3985
+ @if (label() || showTier()) {
3986
+ <div class="min-w-0">
3987
+ @if (label()) {
3988
+ <p class="truncate text-sm font-semibold text-ink">{{ label() }}</p>
3989
+ }
3990
+ @if (showTier()) {
3991
+ <p class="mt-0.5 flex items-center gap-1 text-sm font-medium" [class]="tierStyle().text">
3992
+ <sds-icon [name]="tierStyle().icon" [size]="15" />
3993
+ {{ tierText() }}
3994
+ </p>
3995
+ }
3996
+ </div>
3997
+ }
3998
+ </div>
3999
+ } @else {
4000
+ <div aria-hidden="true">
4001
+ <div class="mb-1.5 flex items-baseline justify-between gap-3">
4002
+ <span class="truncate text-sm font-semibold text-ink">{{ label() }}</span>
4003
+ <span class="flex shrink-0 items-center gap-1.5 text-sm">
4004
+ <span class="font-bold tabular-nums text-ink">{{ clamped() }}<span class="text-ink-faint">/100</span></span>
4005
+ @if (showTier()) {
4006
+ <span class="flex items-center gap-1 font-medium" [class]="tierStyle().text">
4007
+ <sds-icon [name]="tierStyle().icon" [size]="14" />
4008
+ {{ tierText() }}
4009
+ </span>
4010
+ }
4011
+ </span>
4012
+ </div>
4013
+ <div class="w-full overflow-hidden rounded-full bg-border" [class]="barHeight()">
4014
+ <div
4015
+ class="h-full rounded-full transition-[width] duration-500"
4016
+ [class]="tierStyle().fill"
4017
+ [style.width.%]="clamped()"
4018
+ ></div>
4019
+ </div>
4020
+ </div>
4021
+ }
4022
+ </div>
4023
+ `,
4024
+ }]
4025
+ }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], showTier: [{ type: i0.Input, args: [{ isSignal: true, alias: "showTier", required: false }] }], fairFrom: [{ type: i0.Input, args: [{ isSignal: true, alias: "fairFrom", required: false }] }], goodFrom: [{ type: i0.Input, args: [{ isSignal: true, alias: "goodFrom", required: false }] }], poorLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "poorLabel", required: false }] }], fairLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "fairLabel", required: false }] }], goodLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "goodLabel", required: false }] }], valueTextTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueTextTemplate", required: false }] }] } });
4026
+
4027
+ const STATES = {
4028
+ available: {
4029
+ icon: 'check-circle',
4030
+ frame: 'border-border bg-surface',
4031
+ badge: 'bg-accent-green-soft text-accent-green',
4032
+ },
4033
+ 'sold-out': {
4034
+ icon: 'ban',
4035
+ frame: 'border-danger bg-accent-red-soft',
4036
+ badge: 'bg-danger text-white',
4037
+ },
4038
+ hidden: {
4039
+ icon: 'eye-off',
4040
+ frame: 'border-dashed border-ink-faint bg-muted',
4041
+ badge: 'bg-ink/10 text-ink-soft',
4042
+ },
4043
+ };
4044
+ /**
4045
+ * Touch-first square tile for a stock board: a big press target (the whole tile) with a title,
4046
+ * optional subtitle, optional photo and a state — available · sold out · hidden.
4047
+ *
4048
+ * The state is never colour-only: each has its own glyph and word in a badge (check / "Available",
4049
+ * a no-entry sign / "Sold out", a crossed eye / "Hidden"), sold-out tiles get a diagonal hatch and a
4050
+ * struck-through title, hidden tiles a dashed frame. Pressing gives a visible pressed state.
4051
+ *
4052
+ * The tile is one `<button>` that emits `(activate)`. Pass [selected] (true/false) to make it a toggle
4053
+ * for multi-select ("select all matches"): it then carries `aria-pressed` and a corner check. [busy]
4054
+ * shows a spinner and sets `aria-busy` while a save is in flight.
4055
+ *
4056
+ * @example
4057
+ * <sds-status-tile title="Bacon" subtitle="Extras" state="sold-out" note="Back at 22:00"
4058
+ * soldOutLabel="Sold out" (activate)="toggle(item)" />
4059
+ */
4060
+ class SdsStatusTile {
4061
+ title = input.required(...(ngDevMode ? [{ debugName: "title" }] : []));
4062
+ subtitle = input('', ...(ngDevMode ? [{ debugName: "subtitle" }] : []));
4063
+ /** Optional photo URL, shown across the top of the tile. */
4064
+ image = input(null, ...(ngDevMode ? [{ debugName: "image" }] : []));
4065
+ /** Alt text for the photo. Empty (default) marks it decorative — the title already names the tile. */
4066
+ imageAlt = input('', ...(ngDevMode ? [{ debugName: "imageAlt" }] : []));
4067
+ state = input('available', ...(ngDevMode ? [{ debugName: "state" }] : []));
4068
+ /** Short extra line next to the state, e.g. "Back at 22:00". */
4069
+ note = input('', ...(ngDevMode ? [{ debugName: "note" }] : []));
4070
+ /** `null` (default): a plain action tile. `true`/`false`: a toggle for multi-select (`aria-pressed`). */
4071
+ selected = input(null, ...(ngDevMode ? [{ debugName: "selected" }] : []));
4072
+ /** A save is in flight: spinner + `aria-busy`. */
4073
+ busy = input(false, ...(ngDevMode ? [{ debugName: "busy", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
4074
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
4075
+ availableLabel = input('Available', ...(ngDevMode ? [{ debugName: "availableLabel" }] : []));
4076
+ soldOutLabel = input('Sold out', ...(ngDevMode ? [{ debugName: "soldOutLabel" }] : []));
4077
+ hiddenLabel = input('Hidden', ...(ngDevMode ? [{ debugName: "hiddenLabel" }] : []));
4078
+ /** The tile was pressed (click, tap, Enter or Space). */
4079
+ activate = output();
4080
+ style = computed(() => STATES[this.state()] ?? STATES.available, ...(ngDevMode ? [{ debugName: "style" }] : []));
4081
+ stateText = computed(() => {
4082
+ switch (this.state()) {
4083
+ case 'sold-out':
4084
+ return this.soldOutLabel();
4085
+ case 'hidden':
4086
+ return this.hiddenLabel();
4087
+ default:
4088
+ return this.availableLabel();
4089
+ }
4090
+ }, ...(ngDevMode ? [{ debugName: "stateText" }] : []));
4091
+ /** Diagonal hatch on sold-out tiles — a pattern cue on top of the colour. */
4092
+ hatch = computed(() => this.state() === 'sold-out'
4093
+ ? 'repeating-linear-gradient(135deg, transparent 0 12px, color-mix(in srgb, var(--color-danger) 10%, transparent) 12px 24px)'
4094
+ : null, ...(ngDevMode ? [{ debugName: "hatch" }] : []));
4095
+ tileClass = computed(() => {
4096
+ const ring = this.selected()
4097
+ ? ' ring-2 ring-brand-500 ring-offset-2 ring-offset-canvas dark:ring-ink'
4098
+ : '';
4099
+ return ('relative flex aspect-square w-full min-h-32 cursor-pointer select-none flex-col overflow-hidden ' +
4100
+ 'rounded-card border-2 text-left shadow-sm transition-[transform,box-shadow] duration-100 ' +
4101
+ 'hover:shadow-md active:scale-[0.97] active:shadow-none focus-visible:outline-none ' +
4102
+ 'focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 ' +
4103
+ 'focus-visible:ring-offset-canvas disabled:cursor-not-allowed disabled:opacity-60 ' +
4104
+ 'touch-manipulation ' +
4105
+ this.style().frame +
4106
+ (this.state() === 'hidden' ? ' opacity-80' : '') +
4107
+ ring);
4108
+ }, ...(ngDevMode ? [{ debugName: "tileClass" }] : []));
4109
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsStatusTile, deps: [], target: i0.ɵɵFactoryTarget.Component });
4110
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsStatusTile, isStandalone: true, selector: "sds-status-tile", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: true, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, image: { classPropertyName: "image", publicName: "image", isSignal: true, isRequired: false, transformFunction: null }, imageAlt: { classPropertyName: "imageAlt", publicName: "imageAlt", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, note: { classPropertyName: "note", publicName: "note", isSignal: true, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null }, busy: { classPropertyName: "busy", publicName: "busy", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, availableLabel: { classPropertyName: "availableLabel", publicName: "availableLabel", isSignal: true, isRequired: false, transformFunction: null }, soldOutLabel: { classPropertyName: "soldOutLabel", publicName: "soldOutLabel", isSignal: true, isRequired: false, transformFunction: null }, hiddenLabel: { classPropertyName: "hiddenLabel", publicName: "hiddenLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activate: "activate" }, host: { classAttribute: "block" }, ngImport: i0, template: `
4111
+ <button
4112
+ type="button"
4113
+ [class]="tileClass()"
4114
+ [disabled]="disabled()"
4115
+ [attr.aria-pressed]="selected() === null ? null : selected()"
4116
+ [attr.aria-busy]="busy() ? 'true' : null"
4117
+ [attr.data-state]="state()"
4118
+ [style.background-image]="hatch()"
4119
+ (click)="activate.emit()"
4120
+ >
4121
+ @if (image()) {
4122
+ <span class="relative block h-[45%] w-full shrink-0 overflow-hidden bg-muted">
4123
+ <img
4124
+ [src]="image()"
4125
+ [alt]="imageAlt()"
4126
+ class="h-full w-full object-cover"
4127
+ [class.grayscale]="state() !== 'available'"
4128
+ [class.opacity-60]="state() !== 'available'"
4129
+ loading="lazy"
4130
+ draggable="false"
4131
+ />
4132
+ </span>
4133
+ }
4134
+
4135
+ <span class="flex min-h-0 w-full flex-1 flex-col gap-1 p-3 text-left">
4136
+ <span
4137
+ class="line-clamp-2 break-words font-bold leading-tight text-ink"
4138
+ [class]="image() ? 'text-base' : 'text-lg'"
4139
+ [class.line-through]="state() === 'sold-out'"
4140
+ [class.decoration-2]="state() === 'sold-out'"
4141
+ >{{ title() }}</span
4142
+ >
4143
+ @if (subtitle()) {
4144
+ <span class="truncate text-xs text-ink-soft">{{ subtitle() }}</span>
4145
+ }
4146
+
4147
+ <span class="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
4148
+ <span
4149
+ class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-bold"
4150
+ [class]="style().badge"
4151
+ >
4152
+ <sds-icon [name]="style().icon" [size]="14" [strokeWidth]="2.5" />
4153
+ {{ stateText() }}
4154
+ </span>
4155
+ @if (note()) {
4156
+ <span class="truncate text-xs font-medium text-ink-soft">{{ note() }}</span>
4157
+ }
4158
+ </span>
4159
+ </span>
4160
+
4161
+ @if (selected()) {
4162
+ <span
4163
+ class="absolute right-2 top-2 grid h-7 w-7 place-items-center rounded-full bg-brand-600 text-white shadow ring-2 ring-surface dark:bg-ink dark:text-surface"
4164
+ aria-hidden="true"
4165
+ >
4166
+ <sds-icon name="check" [size]="16" [strokeWidth]="3" />
4167
+ </span>
4168
+ } @else if (selected() === false) {
4169
+ <span
4170
+ class="absolute right-2 top-2 h-7 w-7 rounded-full border-2 border-ink-faint bg-surface/80"
4171
+ aria-hidden="true"
4172
+ ></span>
4173
+ }
4174
+
4175
+ @if (busy()) {
4176
+ <span class="absolute inset-0 grid place-items-center bg-surface/60" aria-hidden="true">
4177
+ <span class="h-7 w-7 animate-spin rounded-full border-2 border-brand-200 border-t-brand-600"></span>
4178
+ </span>
4179
+ }
4180
+ </button>
4181
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4182
+ }
4183
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsStatusTile, decorators: [{
4184
+ type: Component,
4185
+ args: [{
4186
+ selector: 'sds-status-tile',
4187
+ changeDetection: ChangeDetectionStrategy.OnPush,
4188
+ imports: [SdsIcon],
4189
+ host: { class: 'block' },
4190
+ template: `
4191
+ <button
4192
+ type="button"
4193
+ [class]="tileClass()"
4194
+ [disabled]="disabled()"
4195
+ [attr.aria-pressed]="selected() === null ? null : selected()"
4196
+ [attr.aria-busy]="busy() ? 'true' : null"
4197
+ [attr.data-state]="state()"
4198
+ [style.background-image]="hatch()"
4199
+ (click)="activate.emit()"
4200
+ >
4201
+ @if (image()) {
4202
+ <span class="relative block h-[45%] w-full shrink-0 overflow-hidden bg-muted">
4203
+ <img
4204
+ [src]="image()"
4205
+ [alt]="imageAlt()"
4206
+ class="h-full w-full object-cover"
4207
+ [class.grayscale]="state() !== 'available'"
4208
+ [class.opacity-60]="state() !== 'available'"
4209
+ loading="lazy"
4210
+ draggable="false"
4211
+ />
4212
+ </span>
4213
+ }
4214
+
4215
+ <span class="flex min-h-0 w-full flex-1 flex-col gap-1 p-3 text-left">
4216
+ <span
4217
+ class="line-clamp-2 break-words font-bold leading-tight text-ink"
4218
+ [class]="image() ? 'text-base' : 'text-lg'"
4219
+ [class.line-through]="state() === 'sold-out'"
4220
+ [class.decoration-2]="state() === 'sold-out'"
4221
+ >{{ title() }}</span
4222
+ >
4223
+ @if (subtitle()) {
4224
+ <span class="truncate text-xs text-ink-soft">{{ subtitle() }}</span>
4225
+ }
4226
+
4227
+ <span class="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
4228
+ <span
4229
+ class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-bold"
4230
+ [class]="style().badge"
4231
+ >
4232
+ <sds-icon [name]="style().icon" [size]="14" [strokeWidth]="2.5" />
4233
+ {{ stateText() }}
4234
+ </span>
4235
+ @if (note()) {
4236
+ <span class="truncate text-xs font-medium text-ink-soft">{{ note() }}</span>
4237
+ }
4238
+ </span>
4239
+ </span>
4240
+
4241
+ @if (selected()) {
4242
+ <span
4243
+ class="absolute right-2 top-2 grid h-7 w-7 place-items-center rounded-full bg-brand-600 text-white shadow ring-2 ring-surface dark:bg-ink dark:text-surface"
4244
+ aria-hidden="true"
4245
+ >
4246
+ <sds-icon name="check" [size]="16" [strokeWidth]="3" />
4247
+ </span>
4248
+ } @else if (selected() === false) {
4249
+ <span
4250
+ class="absolute right-2 top-2 h-7 w-7 rounded-full border-2 border-ink-faint bg-surface/80"
4251
+ aria-hidden="true"
4252
+ ></span>
4253
+ }
4254
+
4255
+ @if (busy()) {
4256
+ <span class="absolute inset-0 grid place-items-center bg-surface/60" aria-hidden="true">
4257
+ <span class="h-7 w-7 animate-spin rounded-full border-2 border-brand-200 border-t-brand-600"></span>
4258
+ </span>
4259
+ }
4260
+ </button>
4261
+ `,
4262
+ }]
4263
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: true }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }], image: [{ type: i0.Input, args: [{ isSignal: true, alias: "image", required: false }] }], imageAlt: [{ type: i0.Input, args: [{ isSignal: true, alias: "imageAlt", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], note: [{ type: i0.Input, args: [{ isSignal: true, alias: "note", required: false }] }], selected: [{ type: i0.Input, args: [{ isSignal: true, alias: "selected", required: false }] }], busy: [{ type: i0.Input, args: [{ isSignal: true, alias: "busy", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], availableLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableLabel", required: false }] }], soldOutLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "soldOutLabel", required: false }] }], hiddenLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenLabel", required: false }] }], activate: [{ type: i0.Output, args: ["activate"] }] } });
4264
+
4265
+ let nextId$b = 0;
4266
+ /**
4267
+ * Marks the element inside `<sds-menu>` that opens it. It may be a native button or a component that
4268
+ * renders one (e.g. `<sds-button sdsMenuTrigger>`): the menu wires `aria-haspopup`, `aria-expanded` and
4269
+ * `aria-controls` onto the first focusable element it finds there.
4270
+ */
4271
+ class SdsMenuTrigger {
4272
+ el = inject(ElementRef);
4273
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsMenuTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
4274
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.25", type: SdsMenuTrigger, isStandalone: true, selector: "[sdsMenuTrigger]", ngImport: i0 });
4275
+ }
4276
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsMenuTrigger, decorators: [{
4277
+ type: Directive,
4278
+ args: [{
4279
+ selector: '[sdsMenuTrigger]',
4280
+ standalone: true,
4281
+ }]
4282
+ }] });
4283
+ /**
4284
+ * Action menu (menu button + popover). The panel is appended to `<body>` and positioned against the
4285
+ * trigger, flipping above it when there is no room below — so it is never clipped by a drawer, a dialog
4286
+ * or a scrolling table, and it sits above them (z 1100).
4287
+ *
4288
+ * Items come from [items]: actions (`{ label, description?, icon?, danger?, disabled? }`) and
4289
+ * `{ separator: true }` dividers. Activating one emits `(select)` with it and closes the menu.
4290
+ *
4291
+ * Trigger: project your own element marked `sdsMenuTrigger`, or let the menu render a compact icon
4292
+ * button (`triggerIcon`, default a horizontal ellipsis) named by [triggerLabel].
4293
+ *
4294
+ * Keyboard (WAI-ARIA menu button): Enter / Space / ArrowDown open on the first item, ArrowUp on the
4295
+ * last; ArrowUp/Down move (wrapping, skipping disabled items), Home/End jump, a letter jumps to the next
4296
+ * item starting with it, Enter/Space activate. **Escape closes only the menu** — the key event is
4297
+ * stopped, so an enclosing drawer or dialog stays open — and focus returns to the trigger. Tab closes it
4298
+ * too. A click outside closes it.
4299
+ *
4300
+ * @example
4301
+ * <sds-menu [items]="[{ id: 'sold', label: 'Mark sold out', icon: 'ban' }, { separator: true },
4302
+ * { id: 'del', label: 'Delete', icon: 'trash-2', danger: true }]"
4303
+ * triggerLabel="Item actions" (select)="onAction($event)" />
4304
+ *
4305
+ * @example
4306
+ * <sds-menu [items]="durations" (select)="snooze($event)">
4307
+ * <sds-button sdsMenuTrigger variant="secondary">Mark sold out</sds-button>
4308
+ * </sds-menu>
4309
+ */
4310
+ class SdsMenu {
4311
+ host = inject(ElementRef);
4312
+ injector = inject(Injector);
4313
+ menuId = `sds-menu-${nextId$b++}`;
4314
+ triggerId = `${this.menuId}-trigger`;
4315
+ /** Actions and separators, in order. */
4316
+ items = input([], ...(ngDevMode ? [{ debugName: "items" }] : []));
4317
+ /** Open state. Two-way: `[(open)]` — set it to open the menu from code. */
4318
+ open = model(false, ...(ngDevMode ? [{ debugName: "open" }] : []));
4319
+ /** Which edge of the trigger the panel lines up with. */
4320
+ align = input('start', ...(ngDevMode ? [{ debugName: "align" }] : []));
4321
+ /** Accessible name of the built-in icon trigger (required when there is no [triggerText]). */
4322
+ triggerLabel = input('More actions', ...(ngDevMode ? [{ debugName: "triggerLabel" }] : []));
4323
+ /** Icon of the built-in trigger. */
4324
+ triggerIcon = input('ellipsis', ...(ngDevMode ? [{ debugName: "triggerIcon" }] : []));
4325
+ /** Optional visible text on the built-in trigger (then it is its name). */
4326
+ triggerText = input('', ...(ngDevMode ? [{ debugName: "triggerText" }] : []));
4327
+ /** Accessible name of the menu itself; defaults to the trigger's name. */
4328
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
4329
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
4330
+ /** Keep the menu open after an item is activated (e.g. toggles). */
4331
+ keepOpen = input(false, ...(ngDevMode ? [{ debugName: "keepOpen", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
4332
+ /** An action was activated. */
4333
+ select = output();
4334
+ projectedTrigger = contentChild(SdsMenuTrigger, ...(ngDevMode ? [{ debugName: "projectedTrigger" }] : []));
4335
+ panelEl = null;
4336
+ /** Which item gets focus when the panel opens: 'first' | 'last' | 'none'. */
4337
+ focusOnOpen = 'first';
4338
+ typeBuffer = '';
4339
+ typeTimer;
4340
+ enabledIndexes = computed(() => this.items()
4341
+ .map((e, i) => (this.isSeparator(e) || e.disabled ? -1 : i))
4342
+ .filter((i) => i >= 0), ...(ngDevMode ? [{ debugName: "enabledIndexes" }] : []));
4343
+ constructor() {
4344
+ // Wire ARIA onto a projected trigger's focusable element (the built-in one binds it in the template).
4345
+ effect(() => {
4346
+ const isOpen = this.open();
4347
+ const trigger = this.projectedTrigger();
4348
+ if (!trigger)
4349
+ return;
4350
+ const el = this.findFocusable(trigger.el.nativeElement);
4351
+ if (!el)
4352
+ return;
4353
+ if (!el.id)
4354
+ el.id = this.triggerId;
4355
+ el.setAttribute('aria-haspopup', 'menu');
4356
+ el.setAttribute('aria-expanded', String(isOpen));
4357
+ if (isOpen)
4358
+ el.setAttribute('aria-controls', this.menuId);
4359
+ else
4360
+ el.removeAttribute('aria-controls');
4361
+ });
4362
+ // Open/close side effects: move the panel to <body>, position it, focus an item; clean up on close.
4363
+ effect(() => {
4364
+ if (this.open()) {
4365
+ untracked(() => afterNextRender(() => this.afterOpen(), { injector: this.injector }));
4366
+ }
4367
+ else {
4368
+ untracked(() => this.teardown());
4369
+ }
4370
+ });
4371
+ }
4372
+ ngOnDestroy() {
4373
+ clearTimeout(this.typeTimer);
4374
+ this.teardown();
4375
+ }
4376
+ // --- helpers ----------------------------------------------------------------
4377
+ isSeparator(entry) {
4378
+ return entry.separator === true;
4379
+ }
4380
+ descId(i) {
4381
+ return `${this.menuId}-desc-${i}`;
4382
+ }
4383
+ itemClass(entry) {
4384
+ const base = 'flex w-full items-start gap-3 px-3.5 py-2 text-left transition-colors outline-none ' +
4385
+ 'focus-visible:ring-2 focus-visible:ring-inset ';
4386
+ if (entry.disabled)
4387
+ return `${base} cursor-not-allowed text-ink-faint opacity-60`;
4388
+ return entry.danger
4389
+ ? `${base} cursor-pointer text-danger hover:bg-danger/10 focus:bg-danger/10 focus-visible:ring-danger/40`
4390
+ : `${base} cursor-pointer text-ink hover:bg-muted focus:bg-muted focus-visible:ring-brand-500/40`;
4391
+ }
4392
+ findFocusable(root) {
4393
+ if (root.matches('button, a[href], [tabindex]'))
4394
+ return root;
4395
+ return root.querySelector('button, a[href], [tabindex]:not([tabindex="-1"])');
4396
+ }
4397
+ triggerEl() {
4398
+ const projected = this.projectedTrigger();
4399
+ if (projected)
4400
+ return this.findFocusable(projected.el.nativeElement);
4401
+ return this.host.nativeElement.querySelector(`#${this.triggerId}`);
4402
+ }
4403
+ isTriggerDisabled() {
4404
+ const el = this.triggerEl();
4405
+ return this.disabled() || !!el?.disabled;
4406
+ }
4407
+ // --- open / close ---------------------------------------------------------
4408
+ show(focus) {
4409
+ if (this.isTriggerDisabled())
4410
+ return;
4411
+ this.focusOnOpen = focus;
4412
+ if (this.open()) {
4413
+ this.focusItemAt(focus);
4414
+ return;
4415
+ }
4416
+ this.open.set(true);
4417
+ }
4418
+ /** Close the menu; with `returnFocus`, focus goes back to the trigger. */
4419
+ close(returnFocus = false) {
4420
+ if (!this.open())
4421
+ return;
4422
+ this.open.set(false);
4423
+ this.teardown();
4424
+ if (returnFocus)
4425
+ this.triggerEl()?.focus();
4426
+ }
4427
+ afterOpen() {
4428
+ if (!this.open())
4429
+ return;
4430
+ this.panelEl = this.host.nativeElement.querySelector('[data-sds-menu-panel]');
4431
+ if (!this.panelEl)
4432
+ return;
4433
+ if (this.panelEl.parentElement !== document.body)
4434
+ document.body.appendChild(this.panelEl);
4435
+ // Name the menu after its trigger unless an explicit [ariaLabel] was given.
4436
+ const trigger = this.triggerEl();
4437
+ if (!this.ariaLabel() && trigger?.id)
4438
+ this.panelEl.setAttribute('aria-labelledby', trigger.id);
4439
+ this.position();
4440
+ this.addReflowListeners();
4441
+ this.focusItemAt(this.focusOnOpen);
4442
+ }
4443
+ teardown() {
4444
+ this.removeReflowListeners();
4445
+ this.panelEl?.remove();
4446
+ this.panelEl = null;
4447
+ }
4448
+ // --- trigger events -------------------------------------------------------
4449
+ onTriggerClick(event) {
4450
+ if (!event.target.closest('button, a, [tabindex]'))
4451
+ return;
4452
+ if (this.open())
4453
+ this.close(false);
4454
+ else
4455
+ this.show('first');
4456
+ }
4457
+ onTriggerKeydown(event) {
4458
+ if (event.key === 'ArrowDown') {
4459
+ event.preventDefault();
4460
+ this.show('first');
4461
+ }
4462
+ else if (event.key === 'ArrowUp') {
4463
+ event.preventDefault();
4464
+ this.show('last');
4465
+ }
4466
+ }
4467
+ // --- menu keyboard --------------------------------------------------------
4468
+ onMenuKeydown(event) {
4469
+ const idx = this.enabledIndexes();
4470
+ const current = Number(document.activeElement?.dataset?.['index'] ?? -1);
4471
+ const pos = idx.indexOf(current);
4472
+ switch (event.key) {
4473
+ case 'ArrowDown':
4474
+ event.preventDefault();
4475
+ if (idx.length)
4476
+ this.focusIndex(idx[(pos + 1 + idx.length) % idx.length]);
4477
+ return;
4478
+ case 'ArrowUp':
4479
+ event.preventDefault();
4480
+ if (idx.length)
4481
+ this.focusIndex(idx[pos <= 0 ? idx.length - 1 : pos - 1]);
4482
+ return;
4483
+ case 'Home':
4484
+ event.preventDefault();
4485
+ this.focusItemAt('first');
4486
+ return;
4487
+ case 'End':
4488
+ event.preventDefault();
4489
+ this.focusItemAt('last');
4490
+ return;
4491
+ case 'Escape':
4492
+ // Close ONLY the menu: stop the event so a drawer/dialog listening for Escape stays open.
4493
+ event.preventDefault();
4494
+ event.stopPropagation();
4495
+ event.stopImmediatePropagation();
4496
+ this.close(true);
4497
+ return;
4498
+ case 'Tab':
4499
+ event.preventDefault();
4500
+ this.close(true);
4501
+ return;
4502
+ case 'Enter':
4503
+ case ' ': {
4504
+ event.preventDefault();
4505
+ const entry = this.items()[current];
4506
+ if (entry && !this.isSeparator(entry))
4507
+ this.activate(entry);
4508
+ return;
4509
+ }
4510
+ default:
4511
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
4512
+ this.typeAhead(event.key, current);
4513
+ }
4514
+ }
4515
+ }
4516
+ typeAhead(char, current) {
4517
+ clearTimeout(this.typeTimer);
4518
+ this.typeBuffer += char.toLowerCase();
4519
+ // "d", "d", "d" cycles through the items starting with d; "de" narrows to "Delete".
4520
+ const repeated = [...this.typeBuffer].every((c) => c === this.typeBuffer[0]);
4521
+ const query = repeated ? this.typeBuffer[0] : this.typeBuffer;
4522
+ const items = this.items();
4523
+ const idx = this.enabledIndexes();
4524
+ const start = Math.max(0, idx.indexOf(current));
4525
+ const from = query.length === 1 ? start + 1 : start;
4526
+ const ordered = [...idx.slice(from), ...idx.slice(0, from)];
4527
+ const hit = ordered.find((i) => items[i].label.toLowerCase().startsWith(query));
4528
+ if (hit !== undefined)
4529
+ this.focusIndex(hit);
4530
+ this.typeTimer = setTimeout(() => (this.typeBuffer = ''), 600);
4531
+ }
4532
+ focusIndex(i) {
4533
+ this.panelEl?.querySelector(`[data-index="${i}"]`)?.focus();
4534
+ }
4535
+ focusItemAt(where) {
4536
+ const idx = this.enabledIndexes();
4537
+ if (!idx.length) {
4538
+ this.panelEl?.focus();
4539
+ return;
4540
+ }
4541
+ this.focusIndex(where === 'first' ? idx[0] : idx[idx.length - 1]);
4542
+ }
4543
+ activate(entry, event) {
4544
+ event?.stopPropagation();
4545
+ if (entry.disabled)
4546
+ return;
4547
+ this.select.emit(entry);
4548
+ if (!this.keepOpen())
4549
+ this.close(true);
4550
+ }
4551
+ // --- outside click ----------------------------------------------------------
4552
+ onDocumentClick(event) {
4553
+ if (!this.open())
4554
+ return;
4555
+ const target = event.target;
4556
+ if (this.host.nativeElement.contains(target))
4557
+ return;
4558
+ if (this.panelEl?.contains(target))
4559
+ return;
4560
+ this.close(false);
4561
+ }
4562
+ // --- positioning (body-appended, mirrors sds-select) ------------------------
4563
+ reflow = () => this.position();
4564
+ listening = false;
4565
+ addReflowListeners() {
4566
+ if (this.listening)
4567
+ return;
4568
+ window.addEventListener('scroll', this.reflow, true);
4569
+ window.addEventListener('resize', this.reflow);
4570
+ this.listening = true;
4571
+ }
4572
+ removeReflowListeners() {
4573
+ if (!this.listening)
4574
+ return;
4575
+ window.removeEventListener('scroll', this.reflow, true);
4576
+ window.removeEventListener('resize', this.reflow);
4577
+ this.listening = false;
4578
+ }
4579
+ position() {
4580
+ const trigger = this.triggerEl();
4581
+ const panel = this.panelEl;
4582
+ if (!trigger || !panel)
4583
+ return;
4584
+ const r = trigger.getBoundingClientRect();
4585
+ const pw = panel.offsetWidth;
4586
+ const ph = panel.offsetHeight;
4587
+ const gap = 4;
4588
+ const margin = 8;
4589
+ let top = r.bottom + gap;
4590
+ if (top + ph > window.innerHeight - margin && r.top - gap - ph > margin) {
4591
+ top = r.top - gap - ph;
4592
+ }
4593
+ top = Math.max(margin, Math.min(top, window.innerHeight - margin - ph));
4594
+ let left = this.align() === 'end' ? r.right - pw : r.left;
4595
+ left = Math.max(margin, Math.min(left, window.innerWidth - margin - pw));
4596
+ panel.style.top = `${Math.round(top)}px`;
4597
+ panel.style.left = `${Math.round(left)}px`;
4598
+ }
4599
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsMenu, deps: [], target: i0.ɵɵFactoryTarget.Component });
4600
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsMenu, isStandalone: true, selector: "sds-menu", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, align: { classPropertyName: "align", publicName: "align", isSignal: true, isRequired: false, transformFunction: null }, triggerLabel: { classPropertyName: "triggerLabel", publicName: "triggerLabel", isSignal: true, isRequired: false, transformFunction: null }, triggerIcon: { classPropertyName: "triggerIcon", publicName: "triggerIcon", isSignal: true, isRequired: false, transformFunction: null }, triggerText: { classPropertyName: "triggerText", publicName: "triggerText", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, keepOpen: { classPropertyName: "keepOpen", publicName: "keepOpen", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", select: "select" }, host: { listeners: { "document:click": "onDocumentClick($event)" }, classAttribute: "relative inline-flex" }, queries: [{ propertyName: "projectedTrigger", first: true, predicate: SdsMenuTrigger, descendants: true, isSignal: true }], ngImport: i0, template: `
4601
+ <span
4602
+ #slot
4603
+ class="inline-flex"
4604
+ (click)="onTriggerClick($event)"
4605
+ (keydown)="onTriggerKeydown($event)"
4606
+ >
4607
+ <ng-content select="[sdsMenuTrigger]" />
4608
+ @if (!projectedTrigger()) {
4609
+ <button
4610
+ type="button"
4611
+ [id]="triggerId"
4612
+ class="inline-flex h-9 min-w-9 items-center justify-center gap-1.5 rounded-control px-2 text-sm font-medium text-ink-soft transition-colors hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:cursor-not-allowed disabled:opacity-50"
4613
+ [class.bg-muted]="open()"
4614
+ [attr.aria-label]="triggerText() ? null : triggerLabel()"
4615
+ [disabled]="disabled()"
4616
+ aria-haspopup="menu"
4617
+ [attr.aria-expanded]="open()"
4618
+ [attr.aria-controls]="open() ? menuId : null"
4619
+ >
4620
+ <sds-icon [name]="triggerIcon()" [size]="18" />
4621
+ @if (triggerText()) {
4622
+ <span>{{ triggerText() }}</span>
4623
+ }
4624
+ </button>
4625
+ }
4626
+ </span>
4627
+
4628
+ @if (open()) {
4629
+ <div
4630
+ data-sds-menu-panel
4631
+ role="menu"
4632
+ [id]="menuId"
4633
+ [attr.aria-label]="ariaLabel() || null"
4634
+ class="fixed z-[1100] min-w-52 max-w-80 overflow-y-auto rounded-card border border-border bg-surface py-1.5 shadow-xl outline-none animate-sds-menu"
4635
+ [style.max-height]="'min(24rem, calc(100vh - 16px))'"
4636
+ (keydown)="onMenuKeydown($event)"
4637
+ >
4638
+ @for (entry of items(); track $index; let i = $index) {
4639
+ @if (isSeparator(entry)) {
4640
+ <div role="separator" class="my-1.5 border-t border-border"></div>
4641
+ } @else {
4642
+ <button
4643
+ type="button"
4644
+ role="menuitem"
4645
+ tabindex="-1"
4646
+ [attr.data-index]="i"
4647
+ [attr.aria-disabled]="entry.disabled ? 'true' : null"
4648
+ [attr.aria-describedby]="entry.description ? descId(i) : null"
4649
+ [class]="itemClass(entry)"
4650
+ (click)="activate(entry, $event)"
4651
+ (mouseenter)="entry.disabled ? null : focusIndex(i)"
4652
+ >
4653
+ @if (entry.icon) {
4654
+ <sds-icon [name]="entry.icon" [size]="18" class="mt-px shrink-0" />
4655
+ }
4656
+ <span class="min-w-0 flex-1 text-left">
4657
+ <span class="block truncate text-sm font-medium">{{ entry.label }}</span>
4658
+ @if (entry.description) {
4659
+ <span
4660
+ [id]="descId(i)"
4661
+ class="mt-0.5 block text-xs"
4662
+ [class]="entry.danger ? 'text-danger/80' : 'text-ink-faint'"
4663
+ >{{ entry.description }}</span
4664
+ >
4665
+ }
4666
+ </span>
4667
+ </button>
4668
+ }
4669
+ }
4670
+ </div>
4671
+ }
4672
+ `, isInline: true, styles: [".animate-sds-menu{animation:sds-menu-pop .12s ease-out}@keyframes sds-menu-pop{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.animate-sds-menu{animation:none}}\n"], dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4673
+ }
4674
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsMenu, decorators: [{
4675
+ type: Component,
4676
+ args: [{ selector: 'sds-menu', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SdsIcon], host: {
4677
+ class: 'relative inline-flex',
4678
+ '(document:click)': 'onDocumentClick($event)',
4679
+ }, template: `
4680
+ <span
4681
+ #slot
4682
+ class="inline-flex"
4683
+ (click)="onTriggerClick($event)"
4684
+ (keydown)="onTriggerKeydown($event)"
4685
+ >
4686
+ <ng-content select="[sdsMenuTrigger]" />
4687
+ @if (!projectedTrigger()) {
4688
+ <button
4689
+ type="button"
4690
+ [id]="triggerId"
4691
+ class="inline-flex h-9 min-w-9 items-center justify-center gap-1.5 rounded-control px-2 text-sm font-medium text-ink-soft transition-colors hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:cursor-not-allowed disabled:opacity-50"
4692
+ [class.bg-muted]="open()"
4693
+ [attr.aria-label]="triggerText() ? null : triggerLabel()"
4694
+ [disabled]="disabled()"
4695
+ aria-haspopup="menu"
4696
+ [attr.aria-expanded]="open()"
4697
+ [attr.aria-controls]="open() ? menuId : null"
4698
+ >
4699
+ <sds-icon [name]="triggerIcon()" [size]="18" />
4700
+ @if (triggerText()) {
4701
+ <span>{{ triggerText() }}</span>
4702
+ }
4703
+ </button>
4704
+ }
4705
+ </span>
4706
+
4707
+ @if (open()) {
4708
+ <div
4709
+ data-sds-menu-panel
4710
+ role="menu"
4711
+ [id]="menuId"
4712
+ [attr.aria-label]="ariaLabel() || null"
4713
+ class="fixed z-[1100] min-w-52 max-w-80 overflow-y-auto rounded-card border border-border bg-surface py-1.5 shadow-xl outline-none animate-sds-menu"
4714
+ [style.max-height]="'min(24rem, calc(100vh - 16px))'"
4715
+ (keydown)="onMenuKeydown($event)"
4716
+ >
4717
+ @for (entry of items(); track $index; let i = $index) {
4718
+ @if (isSeparator(entry)) {
4719
+ <div role="separator" class="my-1.5 border-t border-border"></div>
4720
+ } @else {
4721
+ <button
4722
+ type="button"
4723
+ role="menuitem"
4724
+ tabindex="-1"
4725
+ [attr.data-index]="i"
4726
+ [attr.aria-disabled]="entry.disabled ? 'true' : null"
4727
+ [attr.aria-describedby]="entry.description ? descId(i) : null"
4728
+ [class]="itemClass(entry)"
4729
+ (click)="activate(entry, $event)"
4730
+ (mouseenter)="entry.disabled ? null : focusIndex(i)"
4731
+ >
4732
+ @if (entry.icon) {
4733
+ <sds-icon [name]="entry.icon" [size]="18" class="mt-px shrink-0" />
4734
+ }
4735
+ <span class="min-w-0 flex-1 text-left">
4736
+ <span class="block truncate text-sm font-medium">{{ entry.label }}</span>
4737
+ @if (entry.description) {
4738
+ <span
4739
+ [id]="descId(i)"
4740
+ class="mt-0.5 block text-xs"
4741
+ [class]="entry.danger ? 'text-danger/80' : 'text-ink-faint'"
4742
+ >{{ entry.description }}</span
4743
+ >
4744
+ }
4745
+ </span>
4746
+ </button>
4747
+ }
4748
+ }
4749
+ </div>
4750
+ }
4751
+ `, styles: [".animate-sds-menu{animation:sds-menu-pop .12s ease-out}@keyframes sds-menu-pop{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.animate-sds-menu{animation:none}}\n"] }]
4752
+ }], ctorParameters: () => [], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], align: [{ type: i0.Input, args: [{ isSignal: true, alias: "align", required: false }] }], triggerLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "triggerLabel", required: false }] }], triggerIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "triggerIcon", required: false }] }], triggerText: [{ type: i0.Input, args: [{ isSignal: true, alias: "triggerText", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], keepOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepOpen", required: false }] }], select: [{ type: i0.Output, args: ["select"] }], projectedTrigger: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SdsMenuTrigger), { isSignal: true }] }] } });
4753
+
4754
+ /**
4755
+ * Selection toolbar for bulk editing: "N selected", the actions the app projects, and a clear-selection
4756
+ * button. It shows only while something is selected (`count > 0`) and sticks to the bottom of its
4757
+ * scroll container by default, so it stays in reach while the list scrolls.
4758
+ *
4759
+ * A `role="toolbar"` named by [ariaLabel]; the count sits in a polite live region that exists even
4760
+ * while the bar is hidden, so "3 selected" is announced as the selection changes. Every visible and
4761
+ * spoken string is an input (English defaults).
4762
+ *
4763
+ * @example
4764
+ * <sds-bulk-bar [count]="selected().length" countLabel="{count} selected" (clear)="selected.set([])">
4765
+ * <sds-button size="sm" variant="secondary" (click)="bulkPrice()">Change price</sds-button>
4766
+ * <sds-button size="sm" variant="secondary" (click)="bulkSoldOut()">Mark sold out</sds-button>
4767
+ * </sds-bulk-bar>
4768
+ */
4769
+ class SdsBulkBar {
4770
+ /** Number of selected things. The bar is hidden at 0. */
4771
+ count = input(0, ...(ngDevMode ? [{ debugName: "count", transform: numberAttribute }] : [{ transform: numberAttribute }]));
4772
+ /** "{count} selected" — `{count}` is replaced. Pass a translated string. */
4773
+ countLabel = input('{count} selected', ...(ngDevMode ? [{ debugName: "countLabel" }] : []));
4774
+ /** Text of the clear-selection button. */
4775
+ clearLabel = input('Clear selection', ...(ngDevMode ? [{ debugName: "clearLabel" }] : []));
4776
+ /** Accessible name of the toolbar. */
4777
+ ariaLabel = input('Bulk actions', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
4778
+ position = input('bottom', ...(ngDevMode ? [{ debugName: "position" }] : []));
4779
+ /** The clear-selection button was pressed. */
4780
+ clear = output();
4781
+ countText = computed(() => this.countLabel().replace('{count}', String(this.count())), ...(ngDevMode ? [{ debugName: "countText" }] : []));
4782
+ /** The label without the number, shown next to the count pill ("selected"). */
4783
+ countWords = computed(() => this.countLabel().replace('{count}', '').replace(/\s+/g, ' ').trim(), ...(ngDevMode ? [{ debugName: "countWords" }] : []));
4784
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsBulkBar, deps: [], target: i0.ɵɵFactoryTarget.Component });
4785
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsBulkBar, isStandalone: true, selector: "sds-bulk-bar", inputs: { count: { classPropertyName: "count", publicName: "count", isSignal: true, isRequired: false, transformFunction: null }, countLabel: { classPropertyName: "countLabel", publicName: "countLabel", isSignal: true, isRequired: false, transformFunction: null }, clearLabel: { classPropertyName: "clearLabel", publicName: "clearLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { clear: "clear" }, host: { properties: { "class.sticky": "position() !== 'static' && count() > 0", "class.bottom-3": "position() === 'bottom'", "class.top-3": "position() === 'top'", "class.z-30": "position() !== 'static'" }, classAttribute: "block" }, ngImport: i0, template: `
4786
+ <!-- Always present so screen readers hear the count change, including back to zero. -->
4787
+ <span class="sr-only" aria-live="polite" aria-atomic="true">{{ count() > 0 ? countText() : '' }}</span>
4788
+
4789
+ @if (count() > 0) {
4790
+ <div
4791
+ role="toolbar"
4792
+ [attr.aria-label]="ariaLabel()"
4793
+ class="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-card border border-border bg-surface px-3 py-2 shadow-xl animate-sds-bulk"
4794
+ >
4795
+ <span class="inline-flex items-center gap-2 pr-1 text-sm font-semibold text-ink">
4796
+ <span
4797
+ class="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-brand-600 px-1.5 text-xs font-bold tabular-nums text-white dark:bg-ink dark:text-surface"
4798
+ >{{ count() }}</span
4799
+ >
4800
+ {{ countWords() }}
4801
+ </span>
4802
+
4803
+ <span class="hidden h-6 w-px bg-border sm:block" aria-hidden="true"></span>
4804
+
4805
+ <div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
4806
+ <ng-content />
4807
+ </div>
4808
+
4809
+ <button
4810
+ type="button"
4811
+ class="ml-auto inline-flex h-9 items-center gap-1.5 rounded-control px-2.5 text-sm font-medium text-ink-soft transition-colors hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
4812
+ (click)="clear.emit()"
4813
+ >
4814
+ <sds-icon name="x" [size]="16" />
4815
+ <span>{{ clearLabel() }}</span>
4816
+ </button>
4817
+ </div>
4818
+ }
4819
+ `, isInline: true, styles: [".animate-sds-bulk{animation:sds-bulk-in .16s ease-out}@keyframes sds-bulk-in{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.animate-sds-bulk{animation:none}}\n"], dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4820
+ }
4821
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsBulkBar, decorators: [{
4822
+ type: Component,
4823
+ args: [{ selector: 'sds-bulk-bar', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SdsIcon], host: {
4824
+ class: 'block',
4825
+ '[class.sticky]': "position() !== 'static' && count() > 0",
4826
+ '[class.bottom-3]': "position() === 'bottom'",
4827
+ '[class.top-3]': "position() === 'top'",
4828
+ '[class.z-30]': "position() !== 'static'",
4829
+ }, template: `
4830
+ <!-- Always present so screen readers hear the count change, including back to zero. -->
4831
+ <span class="sr-only" aria-live="polite" aria-atomic="true">{{ count() > 0 ? countText() : '' }}</span>
4832
+
4833
+ @if (count() > 0) {
4834
+ <div
4835
+ role="toolbar"
4836
+ [attr.aria-label]="ariaLabel()"
4837
+ class="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-card border border-border bg-surface px-3 py-2 shadow-xl animate-sds-bulk"
4838
+ >
4839
+ <span class="inline-flex items-center gap-2 pr-1 text-sm font-semibold text-ink">
4840
+ <span
4841
+ class="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-brand-600 px-1.5 text-xs font-bold tabular-nums text-white dark:bg-ink dark:text-surface"
4842
+ >{{ count() }}</span
4843
+ >
4844
+ {{ countWords() }}
4845
+ </span>
4846
+
4847
+ <span class="hidden h-6 w-px bg-border sm:block" aria-hidden="true"></span>
4848
+
4849
+ <div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
4850
+ <ng-content />
4851
+ </div>
4852
+
4853
+ <button
4854
+ type="button"
4855
+ class="ml-auto inline-flex h-9 items-center gap-1.5 rounded-control px-2.5 text-sm font-medium text-ink-soft transition-colors hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
4856
+ (click)="clear.emit()"
4857
+ >
4858
+ <sds-icon name="x" [size]="16" />
4859
+ <span>{{ clearLabel() }}</span>
4860
+ </button>
4861
+ </div>
4862
+ }
4863
+ `, styles: [".animate-sds-bulk{animation:sds-bulk-in .16s ease-out}@keyframes sds-bulk-in{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.animate-sds-bulk{animation:none}}\n"] }]
4864
+ }], propDecorators: { count: [{ type: i0.Input, args: [{ isSignal: true, alias: "count", required: false }] }], countLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "countLabel", required: false }] }], clearLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearLabel", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], position: [{ type: i0.Input, args: [{ isSignal: true, alias: "position", required: false }] }], clear: [{ type: i0.Output, args: ["clear"] }] } });
4865
+
4866
+ /**
4867
+ * Template for one row's content inside `<sds-sortable-list>`.
4868
+ *
4869
+ * @example
4870
+ * <ng-template sdsSortableItem let-item let-i="index">{{ item.name }}</ng-template>
4871
+ */
4872
+ class SdsSortableItem {
4873
+ template = inject(TemplateRef);
4874
+ static ngTemplateContextGuard(_dir, _ctx) {
4875
+ return true;
4876
+ }
4877
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSortableItem, deps: [], target: i0.ɵɵFactoryTarget.Directive });
4878
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.25", type: SdsSortableItem, isStandalone: true, selector: "ng-template[sdsSortableItem]", ngImport: i0 });
4879
+ }
4880
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSortableItem, decorators: [{
4881
+ type: Directive,
4882
+ args: [{
4883
+ selector: 'ng-template[sdsSortableItem]',
4884
+ standalone: true,
4885
+ }]
4886
+ }] });
4887
+ /**
4888
+ * Optional template for the drag handle's content (replaces the default grip icon). The handle itself
4889
+ * stays a library-owned button, so it keeps its accessible name and keyboard behaviour.
4890
+ */
4891
+ class SdsSortableHandle {
4892
+ template = inject(TemplateRef);
4893
+ static ngTemplateContextGuard(_dir, _ctx) {
4894
+ return true;
4895
+ }
4896
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSortableHandle, deps: [], target: i0.ɵɵFactoryTarget.Directive });
4897
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.25", type: SdsSortableHandle, isStandalone: true, selector: "ng-template[sdsSortableHandle]", ngImport: i0 });
4898
+ }
4899
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSortableHandle, decorators: [{
4900
+ type: Directive,
4901
+ args: [{
4902
+ selector: 'ng-template[sdsSortableHandle]',
4903
+ standalone: true,
4904
+ }]
4905
+ }] });
4906
+ let nextId$a = 0;
4907
+ /** Move one entry of `list` from `from` to `to`, returning a new array. */
4908
+ function moveItem(list, from, to) {
4909
+ const next = [...list];
4910
+ const [moved] = next.splice(from, 1);
4911
+ next.splice(to, 0, moved);
4912
+ return next;
4913
+ }
4914
+ /**
4915
+ * Accessible reorderable list, with no `@angular/cdk` dependency.
4916
+ *
4917
+ * - **Pointer** (mouse, pen, touch): drag an item by its handle. The handle has `touch-action: none`, so
4918
+ * a finger drags instead of scrolling the page; the rest of the row still scrolls normally.
4919
+ * - **Keyboard**: focus a handle, **Space/Enter** picks the item up, **↑/↓** (and Home/End) move it,
4920
+ * **Space/Enter** drops it, **Escape** cancels (the event is stopped, so an enclosing drawer stays open).
4921
+ * Moving focus away also cancels.
4922
+ * - **Screen readers**: every handle is named ("Reorder Burgers") and described by the instructions; an
4923
+ * assertive live region announces pick-up, each move ("moved to position 3 of 8"), the drop and a cancel.
4924
+ *
4925
+ * Nothing is committed until the drop: while dragging, the other rows slide to make room. The drop
4926
+ * updates the two-way `[(items)]` model and emits `(reorder)` with the old/new index and the new array.
4927
+ * Items for which [itemDisabled] returns true cannot be picked up (they still shift when others pass).
4928
+ *
4929
+ * Row content comes from `<ng-template sdsSortableItem let-item>`; without one the item's label is shown.
4930
+ * All spoken text is set through inputs (English defaults, `{item}` `{position}` `{total}` tokens).
4931
+ *
4932
+ * @example
4933
+ * <sds-sortable-list [(items)]="categories" dataKey="id" labelField="name" ariaLabel="Categories"
4934
+ * (reorder)="save($event)">
4935
+ * <ng-template sdsSortableItem let-cat>{{ cat.name }} <span>{{ cat.count }} items</span></ng-template>
4936
+ * </sds-sortable-list>
4937
+ */
4938
+ class SdsSortableList {
4939
+ host = inject(ElementRef);
4940
+ injector = inject(Injector);
4941
+ instructionsId = `sds-sortable-${nextId$a++}-help`;
4942
+ /** The items, in order. Two-way: `[(items)]` receives the new order on drop. */
4943
+ items = model([], ...(ngDevMode ? [{ debugName: "items" }] : []));
4944
+ /** Property used as a stable identity for rows (strongly recommended). */
4945
+ dataKey = input('', ...(ngDevMode ? [{ debugName: "dataKey" }] : []));
4946
+ /** Property holding the item's name, for the default row, the handle name and announcements. */
4947
+ labelField = input('label', ...(ngDevMode ? [{ debugName: "labelField" }] : []));
4948
+ /** Function returning the item's name; takes precedence over [labelField]. */
4949
+ itemLabel = input(null, ...(ngDevMode ? [{ debugName: "itemLabel" }] : []));
4950
+ /** Items for which this returns true cannot be picked up. */
4951
+ itemDisabled = input(null, ...(ngDevMode ? [{ debugName: "itemDisabled" }] : []));
4952
+ /** Disable reordering for the whole list. */
4953
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
4954
+ /** `card` (default): bordered rows with a gap. `plain`: rows separated by dividers. */
4955
+ appearance = input('card', ...(ngDevMode ? [{ debugName: "appearance" }] : []));
4956
+ /** Accessible name of the list. */
4957
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
4958
+ /** Name of each handle; `{item}` is the item's label. */
4959
+ handleLabel = input('Reorder {item}', ...(ngDevMode ? [{ debugName: "handleLabel" }] : []));
4960
+ /** Read after a handle's name (aria-describedby). */
4961
+ instructions = input('Press Space or Enter to pick up. Use the arrow keys to move, Space or Enter to drop, Escape to cancel.', ...(ngDevMode ? [{ debugName: "instructions" }] : []));
4962
+ pickedUpMessage = input('{item} picked up. Position {position} of {total}.', ...(ngDevMode ? [{ debugName: "pickedUpMessage" }] : []));
4963
+ movedMessage = input('{item} moved to position {position} of {total}.', ...(ngDevMode ? [{ debugName: "movedMessage" }] : []));
4964
+ droppedMessage = input('{item} dropped at position {position} of {total}.', ...(ngDevMode ? [{ debugName: "droppedMessage" }] : []));
4965
+ cancelledMessage = input('Reorder cancelled. {item} is back at position {position} of {total}.', ...(ngDevMode ? [{ debugName: "cancelledMessage" }] : []));
4966
+ /** A move was completed. */
4967
+ reorder = output();
4968
+ itemTpl = contentChild(SdsSortableItem, ...(ngDevMode ? [{ debugName: "itemTpl" }] : []));
4969
+ handleTpl = contentChild(SdsSortableHandle, ...(ngDevMode ? [{ debugName: "handleTpl" }] : []));
4970
+ /** Rendered order: follows [items] until a drop commits a new one. */
4971
+ working = linkedSignal(() => [...this.items()], ...(ngDevMode ? [{ debugName: "working" }] : []));
4972
+ // --- drag state -------------------------------------------------------------
4973
+ /** Index (in `working`) of the item being moved, or null. */
4974
+ active = signal(null, ...(ngDevMode ? [{ debugName: "active" }] : []));
4975
+ /** Where it would land if dropped now. */
4976
+ target = signal(null, ...(ngDevMode ? [{ debugName: "target" }] : []));
4977
+ mode = signal(null, ...(ngDevMode ? [{ debugName: "mode" }] : []));
4978
+ /** Pointer-mode vertical offset of the dragged row. */
4979
+ pointerDy = signal(0, ...(ngDevMode ? [{ debugName: "pointerDy" }] : []));
4980
+ /** No transitions for one frame while a drop re-renders the new order in place. */
4981
+ settling = signal(false, ...(ngDevMode ? [{ debugName: "settling" }] : []));
4982
+ liveMessage = signal('', ...(ngDevMode ? [{ debugName: "liveMessage" }] : []));
4983
+ /** Row boxes measured at drag start (top/height), plus the gap between rows. */
4984
+ rects = [];
4985
+ gap = 0;
4986
+ pointerId = null;
4987
+ startY = 0;
4988
+ /** Per-row offsets that open a gap where the moving row will land. */
4989
+ shifts = computed(() => {
4990
+ const a = this.active();
4991
+ const t = this.target();
4992
+ const n = this.working().length;
4993
+ const out = new Array(n).fill(0);
4994
+ if (a === null || t === null || !this.rects[a])
4995
+ return out;
4996
+ const step = this.rects[a].height + this.gap;
4997
+ for (let j = 0; j < n; j++) {
4998
+ if (j === a)
4999
+ continue;
5000
+ if (a < t && j > a && j <= t)
5001
+ out[j] = -step;
5002
+ else if (t < a && j >= t && j < a)
5003
+ out[j] = step;
5004
+ }
5005
+ return out;
5006
+ }, ...(ngDevMode ? [{ debugName: "shifts" }] : []));
5007
+ /** Offset of the moving row: follows the pointer, or jumps slot to slot with the keyboard. */
5008
+ activeOffset = computed(() => {
5009
+ const a = this.active();
5010
+ const t = this.target();
5011
+ if (a === null || t === null)
5012
+ return 0;
5013
+ if (this.mode() === 'pointer')
5014
+ return this.pointerDy();
5015
+ let d = 0;
5016
+ if (t > a)
5017
+ for (let j = a + 1; j <= t; j++)
5018
+ d += (this.rects[j]?.height ?? 0) + this.gap;
5019
+ else
5020
+ for (let j = t; j < a; j++)
5021
+ d -= (this.rects[j]?.height ?? 0) + this.gap;
5022
+ return d;
5023
+ }, ...(ngDevMode ? [{ debugName: "activeOffset" }] : []));
5024
+ // --- rendering helpers ------------------------------------------------------
5025
+ trackKey(item, index) {
5026
+ const k = this.dataKey();
5027
+ if (k && item && typeof item === 'object')
5028
+ return item[k] ?? index;
5029
+ return item ?? index;
5030
+ }
5031
+ labelOf(item) {
5032
+ const fn = this.itemLabel();
5033
+ if (fn)
5034
+ return fn(item);
5035
+ if (item && typeof item === 'object') {
5036
+ const v = item[this.labelField()];
5037
+ return v == null ? '' : String(v);
5038
+ }
5039
+ return String(item);
5040
+ }
5041
+ isItemDisabled(item) {
5042
+ return this.itemDisabled()?.(item) ?? false;
5043
+ }
5044
+ context(item, index) {
5045
+ return { $implicit: item, item, index, grabbed: this.active() === index };
5046
+ }
5047
+ handleText(item) {
5048
+ return this.handleLabel().replace('{item}', this.labelOf(item));
5049
+ }
5050
+ transformFor(i) {
5051
+ if (this.active() === null)
5052
+ return null;
5053
+ const y = i === this.active() ? this.activeOffset() : this.shifts()[i];
5054
+ return y ? `translate3d(0, ${y}px, 0)` : null;
5055
+ }
5056
+ rowClass(i) {
5057
+ const isActive = this.active() === i;
5058
+ const moving = this.active() !== null;
5059
+ const card = this.appearance() === 'card';
5060
+ const parts = ['relative flex items-center gap-2'];
5061
+ parts.push(card ? 'rounded-control border border-border bg-surface px-2 py-2' : 'bg-surface px-1 py-2');
5062
+ // Neighbours glide; the dragged row tracks the pointer 1:1 (no easing), and nothing animates
5063
+ // during the settle frame after a drop.
5064
+ if (moving && !this.settling() && !(isActive && this.mode() === 'pointer')) {
5065
+ parts.push('transition-transform duration-150 ease-out');
5066
+ }
5067
+ if (isActive)
5068
+ parts.push('z-10 shadow-lg ring-2 ring-brand-500');
5069
+ return parts.join(' ');
5070
+ }
5071
+ handleClass(item) {
5072
+ const base = 'grid h-9 w-8 shrink-0 place-items-center rounded-control text-ink-faint transition-colors ' +
5073
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ' +
5074
+ 'disabled:cursor-not-allowed disabled:opacity-40 touch-none select-none';
5075
+ return this.disabled() || this.isItemDisabled(item)
5076
+ ? base
5077
+ : `${base} cursor-grab hover:bg-muted hover:text-ink active:cursor-grabbing`;
5078
+ }
5079
+ // --- keyboard ---------------------------------------------------------------
5080
+ onHandleKeydown(event, i) {
5081
+ if (this.mode() === 'pointer')
5082
+ return;
5083
+ const a = this.active();
5084
+ if (a === null) {
5085
+ if (event.key === ' ' || event.key === 'Enter') {
5086
+ event.preventDefault();
5087
+ this.begin(i, 'keyboard');
5088
+ this.announce(this.pickedUpMessage(), i);
5089
+ }
5090
+ return;
5091
+ }
5092
+ const last = this.working().length - 1;
5093
+ const t = this.target() ?? a;
5094
+ switch (event.key) {
5095
+ case 'ArrowUp':
5096
+ case 'ArrowLeft':
5097
+ event.preventDefault();
5098
+ this.keyMove(Math.max(0, t - 1));
5099
+ return;
5100
+ case 'ArrowDown':
5101
+ case 'ArrowRight':
5102
+ event.preventDefault();
5103
+ this.keyMove(Math.min(last, t + 1));
5104
+ return;
5105
+ case 'Home':
5106
+ event.preventDefault();
5107
+ this.keyMove(0);
5108
+ return;
5109
+ case 'End':
5110
+ event.preventDefault();
5111
+ this.keyMove(last);
5112
+ return;
5113
+ case ' ':
5114
+ case 'Enter':
5115
+ event.preventDefault();
5116
+ this.commit();
5117
+ return;
5118
+ case 'Escape':
5119
+ event.preventDefault();
5120
+ event.stopPropagation();
5121
+ this.cancel();
5122
+ return;
5123
+ case 'Tab':
5124
+ this.cancel();
5125
+ return;
5126
+ }
5127
+ }
5128
+ keyMove(to) {
5129
+ if (to === this.target())
5130
+ return;
5131
+ this.target.set(to);
5132
+ this.announce(this.movedMessage(), to);
5133
+ }
5134
+ /** Focus leaving the list while an item is picked up cancels the move. */
5135
+ onFocusOut(event) {
5136
+ if (this.mode() !== 'keyboard')
5137
+ return;
5138
+ const next = event.relatedTarget;
5139
+ if (next && this.host.nativeElement.contains(next))
5140
+ return;
5141
+ this.cancel();
5142
+ }
5143
+ // --- pointer ------------------------------------------------------------------
5144
+ onPointerDown(event, i) {
5145
+ if (this.active() !== null || event.button !== 0)
5146
+ return;
5147
+ const item = this.working()[i];
5148
+ if (this.disabled() || this.isItemDisabled(item))
5149
+ return;
5150
+ event.preventDefault();
5151
+ event.currentTarget.setPointerCapture?.(event.pointerId);
5152
+ event.currentTarget.focus({ preventScroll: true });
5153
+ this.pointerId = event.pointerId;
5154
+ this.startY = event.clientY;
5155
+ this.pointerDy.set(0);
5156
+ this.begin(i, 'pointer');
5157
+ }
5158
+ onPointerMove(event) {
5159
+ if (this.mode() !== 'pointer' || event.pointerId !== this.pointerId)
5160
+ return;
5161
+ const a = this.active();
5162
+ if (a === null)
5163
+ return;
5164
+ const dy = event.clientY - this.startY;
5165
+ this.pointerDy.set(dy);
5166
+ const center = this.rects[a].top + this.rects[a].height / 2 + dy;
5167
+ let t = 0;
5168
+ this.rects.forEach((r, j) => {
5169
+ if (j !== a && r.top + r.height / 2 < center)
5170
+ t++;
5171
+ });
5172
+ this.target.set(t);
5173
+ }
5174
+ onPointerUp(event) {
5175
+ if (this.mode() !== 'pointer' || event.pointerId !== this.pointerId)
5176
+ return;
5177
+ this.commit();
5178
+ }
5179
+ onPointerCancel() {
5180
+ if (this.mode() === 'pointer')
5181
+ this.cancel(false);
5182
+ }
5183
+ // --- lifecycle of a move -------------------------------------------------------
5184
+ begin(i, mode) {
5185
+ this.measure();
5186
+ this.active.set(i);
5187
+ this.target.set(i);
5188
+ this.mode.set(mode);
5189
+ }
5190
+ measure() {
5191
+ const rows = Array.from(this.host.nativeElement.querySelectorAll('li[data-sortable-index]'));
5192
+ this.rects = rows.map((r) => {
5193
+ const b = r.getBoundingClientRect();
5194
+ return { top: b.top, height: b.height };
5195
+ });
5196
+ this.gap =
5197
+ this.rects.length > 1
5198
+ ? Math.max(0, this.rects[1].top - (this.rects[0].top + this.rects[0].height))
5199
+ : 0;
5200
+ }
5201
+ commit() {
5202
+ const from = this.active();
5203
+ const to = this.target();
5204
+ const mode = this.mode();
5205
+ if (from === null || to === null)
5206
+ return this.reset();
5207
+ const item = this.working()[from];
5208
+ if (to === from) {
5209
+ this.reset();
5210
+ this.announce(this.droppedMessage(), to, item);
5211
+ if (mode === 'keyboard')
5212
+ this.focusHandle(to);
5213
+ return;
5214
+ }
5215
+ const next = moveItem(this.working(), from, to);
5216
+ // Swap order and clear the offsets in the same frame, with transitions off, so rows don't jump.
5217
+ this.settling.set(true);
5218
+ this.reset();
5219
+ this.working.set(next);
5220
+ this.items.set(next);
5221
+ this.reorder.emit({ previousIndex: from, currentIndex: to, item, items: next });
5222
+ this.announce(this.droppedMessage(), to, item);
5223
+ this.focusHandle(to);
5224
+ }
5225
+ /** Nothing was moved in the DOM while dragging, so focus is still where it should be. */
5226
+ cancel(announce = true) {
5227
+ const from = this.active();
5228
+ if (from === null)
5229
+ return;
5230
+ this.reset();
5231
+ if (announce)
5232
+ this.announce(this.cancelledMessage(), from);
5233
+ }
5234
+ reset() {
5235
+ this.active.set(null);
5236
+ this.target.set(null);
5237
+ this.mode.set(null);
5238
+ this.pointerDy.set(0);
5239
+ this.pointerId = null;
5240
+ }
5241
+ focusHandle(index) {
5242
+ afterNextRender(() => {
5243
+ this.settling.set(false);
5244
+ this.host.nativeElement
5245
+ .querySelector(`[data-sortable-handle="${index}"]`)
5246
+ ?.focus({ preventScroll: false });
5247
+ }, { injector: this.injector });
5248
+ }
5249
+ announce(template, position, item) {
5250
+ const it = item ?? this.working()[this.active() ?? position];
5251
+ const text = template
5252
+ .replace('{item}', it === undefined ? '' : this.labelOf(it))
5253
+ .replace('{position}', String(position + 1))
5254
+ .replace('{total}', String(this.working().length));
5255
+ // Re-set even when unchanged, so a repeated message is re-announced.
5256
+ this.liveMessage.set('');
5257
+ queueMicrotask(() => this.liveMessage.set(text));
5258
+ }
5259
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSortableList, deps: [], target: i0.ɵɵFactoryTarget.Component });
5260
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsSortableList, isStandalone: true, selector: "sds-sortable-list", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, dataKey: { classPropertyName: "dataKey", publicName: "dataKey", isSignal: true, isRequired: false, transformFunction: null }, labelField: { classPropertyName: "labelField", publicName: "labelField", isSignal: true, isRequired: false, transformFunction: null }, itemLabel: { classPropertyName: "itemLabel", publicName: "itemLabel", isSignal: true, isRequired: false, transformFunction: null }, itemDisabled: { classPropertyName: "itemDisabled", publicName: "itemDisabled", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, handleLabel: { classPropertyName: "handleLabel", publicName: "handleLabel", isSignal: true, isRequired: false, transformFunction: null }, instructions: { classPropertyName: "instructions", publicName: "instructions", isSignal: true, isRequired: false, transformFunction: null }, pickedUpMessage: { classPropertyName: "pickedUpMessage", publicName: "pickedUpMessage", isSignal: true, isRequired: false, transformFunction: null }, movedMessage: { classPropertyName: "movedMessage", publicName: "movedMessage", isSignal: true, isRequired: false, transformFunction: null }, droppedMessage: { classPropertyName: "droppedMessage", publicName: "droppedMessage", isSignal: true, isRequired: false, transformFunction: null }, cancelledMessage: { classPropertyName: "cancelledMessage", publicName: "cancelledMessage", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { items: "itemsChange", reorder: "reorder" }, host: { classAttribute: "block" }, queries: [{ propertyName: "itemTpl", first: true, predicate: SdsSortableItem, descendants: true, isSignal: true }, { propertyName: "handleTpl", first: true, predicate: SdsSortableHandle, descendants: true, isSignal: true }], ngImport: i0, template: `
5261
+ <ul
5262
+ role="list"
5263
+ [attr.aria-label]="ariaLabel() || null"
5264
+ class="flex flex-col"
5265
+ [class.gap-2]="appearance() === 'card'"
5266
+ [class.divide-y]="appearance() === 'plain'"
5267
+ [class.divide-border]="appearance() === 'plain'"
5268
+ (focusout)="onFocusOut($event)"
5269
+ >
5270
+ @for (item of working(); track trackKey(item, $index); let i = $index) {
5271
+ <li
5272
+ [attr.data-sortable-index]="i"
5273
+ [class]="rowClass(i)"
5274
+ [style.transform]="transformFor(i)"
5275
+ >
5276
+ <button
5277
+ type="button"
5278
+ [class]="handleClass(item)"
5279
+ [attr.aria-label]="handleText(item)"
5280
+ [attr.aria-describedby]="instructionsId"
5281
+ [attr.aria-pressed]="mode() === 'keyboard' && active() === i ? 'true' : 'false'"
5282
+ [attr.data-sortable-handle]="i"
5283
+ [disabled]="disabled() || isItemDisabled(item)"
5284
+ (keydown)="onHandleKeydown($event, i)"
5285
+ (pointerdown)="onPointerDown($event, i)"
5286
+ (pointermove)="onPointerMove($event)"
5287
+ (pointerup)="onPointerUp($event)"
5288
+ (pointercancel)="onPointerCancel()"
5289
+ >
5290
+ @if (handleTpl()) {
5291
+ <ng-container
5292
+ [ngTemplateOutlet]="handleTpl()!.template"
5293
+ [ngTemplateOutletContext]="context(item, i)"
5294
+ />
5295
+ } @else {
5296
+ <sds-icon name="grip-vertical" [size]="18" />
5297
+ }
5298
+ </button>
5299
+
5300
+ <div class="min-w-0 flex-1">
5301
+ @if (itemTpl()) {
5302
+ <ng-container
5303
+ [ngTemplateOutlet]="itemTpl()!.template"
5304
+ [ngTemplateOutletContext]="context(item, i)"
5305
+ />
5306
+ } @else {
5307
+ <span class="text-sm text-ink">{{ labelOf(item) }}</span>
5308
+ }
5309
+ </div>
5310
+ </li>
5311
+ }
5312
+ </ul>
5313
+
5314
+ <p [id]="instructionsId" class="sr-only">{{ instructions() }}</p>
5315
+ <div class="sr-only" aria-live="assertive" aria-atomic="true">{{ liveMessage() }}</div>
5316
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5317
+ }
5318
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsSortableList, decorators: [{
5319
+ type: Component,
5320
+ args: [{
5321
+ selector: 'sds-sortable-list',
5322
+ changeDetection: ChangeDetectionStrategy.OnPush,
5323
+ imports: [NgTemplateOutlet, SdsIcon],
5324
+ host: { class: 'block' },
5325
+ template: `
5326
+ <ul
5327
+ role="list"
5328
+ [attr.aria-label]="ariaLabel() || null"
5329
+ class="flex flex-col"
5330
+ [class.gap-2]="appearance() === 'card'"
5331
+ [class.divide-y]="appearance() === 'plain'"
5332
+ [class.divide-border]="appearance() === 'plain'"
5333
+ (focusout)="onFocusOut($event)"
5334
+ >
5335
+ @for (item of working(); track trackKey(item, $index); let i = $index) {
5336
+ <li
5337
+ [attr.data-sortable-index]="i"
5338
+ [class]="rowClass(i)"
5339
+ [style.transform]="transformFor(i)"
5340
+ >
5341
+ <button
5342
+ type="button"
5343
+ [class]="handleClass(item)"
5344
+ [attr.aria-label]="handleText(item)"
5345
+ [attr.aria-describedby]="instructionsId"
5346
+ [attr.aria-pressed]="mode() === 'keyboard' && active() === i ? 'true' : 'false'"
5347
+ [attr.data-sortable-handle]="i"
5348
+ [disabled]="disabled() || isItemDisabled(item)"
5349
+ (keydown)="onHandleKeydown($event, i)"
5350
+ (pointerdown)="onPointerDown($event, i)"
5351
+ (pointermove)="onPointerMove($event)"
5352
+ (pointerup)="onPointerUp($event)"
5353
+ (pointercancel)="onPointerCancel()"
5354
+ >
5355
+ @if (handleTpl()) {
5356
+ <ng-container
5357
+ [ngTemplateOutlet]="handleTpl()!.template"
5358
+ [ngTemplateOutletContext]="context(item, i)"
5359
+ />
5360
+ } @else {
5361
+ <sds-icon name="grip-vertical" [size]="18" />
5362
+ }
5363
+ </button>
5364
+
5365
+ <div class="min-w-0 flex-1">
5366
+ @if (itemTpl()) {
5367
+ <ng-container
5368
+ [ngTemplateOutlet]="itemTpl()!.template"
5369
+ [ngTemplateOutletContext]="context(item, i)"
5370
+ />
5371
+ } @else {
5372
+ <span class="text-sm text-ink">{{ labelOf(item) }}</span>
5373
+ }
5374
+ </div>
5375
+ </li>
5376
+ }
5377
+ </ul>
5378
+
5379
+ <p [id]="instructionsId" class="sr-only">{{ instructions() }}</p>
5380
+ <div class="sr-only" aria-live="assertive" aria-atomic="true">{{ liveMessage() }}</div>
5381
+ `,
5382
+ }]
5383
+ }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }, { type: i0.Output, args: ["itemsChange"] }], dataKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataKey", required: false }] }], labelField: [{ type: i0.Input, args: [{ isSignal: true, alias: "labelField", required: false }] }], itemLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemLabel", required: false }] }], itemDisabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemDisabled", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], handleLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "handleLabel", required: false }] }], instructions: [{ type: i0.Input, args: [{ isSignal: true, alias: "instructions", required: false }] }], pickedUpMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "pickedUpMessage", required: false }] }], movedMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "movedMessage", required: false }] }], droppedMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppedMessage", required: false }] }], cancelledMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "cancelledMessage", required: false }] }], reorder: [{ type: i0.Output, args: ["reorder"] }], itemTpl: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SdsSortableItem), { isSignal: true }] }], handleTpl: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SdsSortableHandle), { isSignal: true }] }] } });
5384
+
5385
+ let nextId$9 = 0;
5386
+ /** "1.5 MB" style size (decimal units, like file managers show them), for messages. */
5387
+ function sdsFormatBytes(bytes) {
5388
+ if (!Number.isFinite(bytes) || bytes < 0)
5389
+ return '';
5390
+ if (bytes < 1000)
5391
+ return `${bytes} B`;
5392
+ const units = ['kB', 'MB', 'GB'];
5393
+ let v = bytes / 1000;
5394
+ let u = 0;
5395
+ while (v >= 1000 && u < units.length - 1) {
5396
+ v /= 1000;
5397
+ u++;
5398
+ }
5399
+ return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[u]}`;
5400
+ }
5401
+ /** Does `file` match an `accept` string (".jpg,image/png,image/*")? Empty accepts everything. */
5402
+ function sdsFileMatchesAccept(file, accept) {
5403
+ const rules = accept
5404
+ .split(',')
5405
+ .map((r) => r.trim().toLowerCase())
5406
+ .filter(Boolean);
5407
+ if (!rules.length)
5408
+ return true;
5409
+ const name = file.name.toLowerCase();
5410
+ const type = (file.type || '').toLowerCase();
5411
+ return rules.some((rule) => {
5412
+ if (rule.startsWith('.'))
5413
+ return name.endsWith(rule);
5414
+ if (rule.endsWith('/*'))
5415
+ return type.startsWith(rule.slice(0, -1));
5416
+ return type === rule;
5417
+ });
5418
+ }
5419
+ /**
5420
+ * Drop zone plus a "Browse" button for picking files. Filters by [accept] (same syntax as the native
5421
+ * input: `.csv`, `image/*`, `image/png`), [maxSize] (bytes) and [maxFiles]; emits the accepted files as
5422
+ * `(files)` and the rest as `(rejected)` with a reason and a readable message, which is also shown
5423
+ * under the zone and announced.
5424
+ *
5425
+ * Keyboard and screen-reader users use the native Browse button (the zone itself is a pointer
5426
+ * affordance); clicking anywhere on the zone opens the picker too. All text is set through inputs with
5427
+ * English defaults.
5428
+ *
5429
+ * @example
5430
+ * <sds-file-drop accept="image/jpeg,image/png,image/webp" multiple [maxSize]="10_000_000"
5431
+ * label="Drop photos here" browseLabel="Choose photos"
5432
+ * (files)="upload($event)" (rejected)="onRejected($event)" />
5433
+ */
5434
+ class SdsFileDrop {
5435
+ zoneId = `sds-file-drop-${nextId$9++}`;
5436
+ fileInput = viewChild('fileInput', ...(ngDevMode ? [{ debugName: "fileInput" }] : []));
5437
+ /** Accepted types, native `accept` syntax. Empty = anything. */
5438
+ accept = input('', ...(ngDevMode ? [{ debugName: "accept" }] : []));
5439
+ multiple = input(false, ...(ngDevMode ? [{ debugName: "multiple", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
5440
+ /** Largest accepted file in bytes. Null = no limit. */
5441
+ maxSize = input(null, ...(ngDevMode ? [{ debugName: "maxSize" }] : []));
5442
+ /** Most files accepted in one pick/drop. Null = no limit (1 when not [multiple]). */
5443
+ maxFiles = input(null, ...(ngDevMode ? [{ debugName: "maxFiles" }] : []));
5444
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
5445
+ /** Icon in the zone at rest. */
5446
+ icon = input('upload', ...(ngDevMode ? [{ debugName: "icon" }] : []));
5447
+ label = input('Drag and drop files here', ...(ngDevMode ? [{ debugName: "label" }] : []));
5448
+ /** Shown while files are dragged over the zone. */
5449
+ dropLabel = input('Drop to add', ...(ngDevMode ? [{ debugName: "dropLabel" }] : []));
5450
+ browseLabel = input('Browse files', ...(ngDevMode ? [{ debugName: "browseLabel" }] : []));
5451
+ /** Extra line under the label, e.g. "JPEG or PNG, up to 10 MB". */
5452
+ hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
5453
+ /** Tokens: `{name}`. */
5454
+ wrongTypeMessage = input('{name}: this file type is not accepted.', ...(ngDevMode ? [{ debugName: "wrongTypeMessage" }] : []));
5455
+ /** Tokens: `{name}`, `{max}` (formatted size). */
5456
+ tooLargeMessage = input('{name}: larger than {max}.', ...(ngDevMode ? [{ debugName: "tooLargeMessage" }] : []));
5457
+ /** Tokens: `{name}`, `{max}` (file count). */
5458
+ tooManyMessage = input('{name}: only {max} file(s) can be added at once.', ...(ngDevMode ? [{ debugName: "tooManyMessage" }] : []));
5459
+ /** Files that passed every check, in pick/drop order. */
5460
+ files = output();
5461
+ /** Files that were turned away, with the reason. */
5462
+ rejected = output();
5463
+ dragging = signal(false, ...(ngDevMode ? [{ debugName: "dragging" }] : []));
5464
+ errors = signal([], ...(ngDevMode ? [{ debugName: "errors" }] : []));
5465
+ dragDepth = 0;
5466
+ describedBy = computed(() => {
5467
+ const ids = [`${this.zoneId}-label`];
5468
+ if (this.hint())
5469
+ ids.push(`${this.zoneId}-hint`);
5470
+ if (this.errors().length)
5471
+ ids.push(`${this.zoneId}-errors`);
5472
+ return ids.join(' ');
5473
+ }, ...(ngDevMode ? [{ debugName: "describedBy" }] : []));
5474
+ zoneClass = computed(() => {
5475
+ const base = 'flex flex-col items-center justify-center gap-2 rounded-card border-2 border-dashed px-6 py-8 ' +
5476
+ 'text-center transition-colors';
5477
+ if (this.disabled())
5478
+ return `${base} cursor-not-allowed border-border bg-muted opacity-60`;
5479
+ return this.dragging()
5480
+ ? `${base} cursor-copy border-brand-500 bg-brand-50 dark:bg-muted`
5481
+ : `${base} cursor-pointer border-border bg-muted/50 hover:border-ink-faint`;
5482
+ }, ...(ngDevMode ? [{ debugName: "zoneClass" }] : []));
5483
+ /** Open the native file picker. */
5484
+ browse(event) {
5485
+ event?.stopPropagation();
5486
+ if (this.disabled())
5487
+ return;
5488
+ this.fileInput()?.nativeElement.click();
5489
+ }
5490
+ onZoneClick(event) {
5491
+ if (event.target.closest('button, input'))
5492
+ return;
5493
+ this.browse();
5494
+ }
5495
+ onDragEnter(event) {
5496
+ if (this.disabled() || !this.hasFiles(event))
5497
+ return;
5498
+ event.preventDefault();
5499
+ this.dragDepth++;
5500
+ this.dragging.set(true);
5501
+ }
5502
+ onDragOver(event) {
5503
+ if (this.disabled() || !this.hasFiles(event))
5504
+ return;
5505
+ event.preventDefault();
5506
+ if (event.dataTransfer)
5507
+ event.dataTransfer.dropEffect = 'copy';
5508
+ }
5509
+ onDragLeave(event) {
5510
+ if (this.disabled() || !this.hasFiles(event))
5511
+ return;
5512
+ this.dragDepth = Math.max(0, this.dragDepth - 1);
5513
+ if (this.dragDepth === 0)
5514
+ this.dragging.set(false);
5515
+ }
5516
+ onDrop(event) {
5517
+ if (this.disabled())
5518
+ return;
5519
+ event.preventDefault();
5520
+ this.dragDepth = 0;
5521
+ this.dragging.set(false);
5522
+ this.handle(Array.from(event.dataTransfer?.files ?? []));
5523
+ }
5524
+ onInputChange(event) {
5525
+ const el = event.target;
5526
+ this.handle(Array.from(el.files ?? []));
5527
+ // Reset so picking the same file again still fires `change`.
5528
+ el.value = '';
5529
+ }
5530
+ hasFiles(event) {
5531
+ return Array.from(event.dataTransfer?.types ?? []).includes('Files');
5532
+ }
5533
+ handle(list) {
5534
+ if (!list.length)
5535
+ return;
5536
+ const accepted = [];
5537
+ const rejected = [];
5538
+ const max = this.maxFiles() ?? (this.multiple() ? null : 1);
5539
+ const maxSize = this.maxSize();
5540
+ for (const file of list) {
5541
+ if (!sdsFileMatchesAccept(file, this.accept())) {
5542
+ rejected.push({ file, reason: 'type', message: this.render(this.wrongTypeMessage(), file) });
5543
+ }
5544
+ else if (maxSize !== null && file.size > maxSize) {
5545
+ rejected.push({
5546
+ file,
5547
+ reason: 'size',
5548
+ message: this.render(this.tooLargeMessage(), file, sdsFormatBytes(maxSize)),
5549
+ });
5550
+ }
5551
+ else if (max !== null && accepted.length >= max) {
5552
+ rejected.push({
5553
+ file,
5554
+ reason: 'count',
5555
+ message: this.render(this.tooManyMessage(), file, String(max)),
5556
+ });
5557
+ }
5558
+ else {
5559
+ accepted.push(file);
5560
+ }
5561
+ }
5562
+ this.errors.set(rejected.map((r) => r.message));
5563
+ if (accepted.length)
5564
+ this.files.emit(accepted);
5565
+ if (rejected.length)
5566
+ this.rejected.emit(rejected);
5567
+ }
5568
+ render(template, file, max = '') {
5569
+ return template.replace('{name}', file.name).replace('{max}', max);
5570
+ }
5571
+ /** Clear the error messages under the zone. */
5572
+ clearErrors() {
5573
+ this.errors.set([]);
5574
+ }
5575
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsFileDrop, deps: [], target: i0.ɵɵFactoryTarget.Component });
5576
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsFileDrop, isStandalone: true, selector: "sds-file-drop", inputs: { accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, maxSize: { classPropertyName: "maxSize", publicName: "maxSize", isSignal: true, isRequired: false, transformFunction: null }, maxFiles: { classPropertyName: "maxFiles", publicName: "maxFiles", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, dropLabel: { classPropertyName: "dropLabel", publicName: "dropLabel", isSignal: true, isRequired: false, transformFunction: null }, browseLabel: { classPropertyName: "browseLabel", publicName: "browseLabel", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, wrongTypeMessage: { classPropertyName: "wrongTypeMessage", publicName: "wrongTypeMessage", isSignal: true, isRequired: false, transformFunction: null }, tooLargeMessage: { classPropertyName: "tooLargeMessage", publicName: "tooLargeMessage", isSignal: true, isRequired: false, transformFunction: null }, tooManyMessage: { classPropertyName: "tooManyMessage", publicName: "tooManyMessage", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { files: "files", rejected: "rejected" }, host: { classAttribute: "block" }, viewQueries: [{ propertyName: "fileInput", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: `
5577
+ <div
5578
+ [class]="zoneClass()"
5579
+ [attr.aria-disabled]="disabled() ? 'true' : null"
5580
+ [attr.data-dragging]="dragging() ? 'true' : null"
5581
+ (click)="onZoneClick($event)"
5582
+ (dragenter)="onDragEnter($event)"
5583
+ (dragover)="onDragOver($event)"
5584
+ (dragleave)="onDragLeave($event)"
5585
+ (drop)="onDrop($event)"
5586
+ >
5587
+ <span
5588
+ class="grid h-11 w-11 place-items-center rounded-full bg-surface text-ink-soft shadow-sm ring-1 ring-border"
5589
+ aria-hidden="true"
5590
+ >
5591
+ <sds-icon [name]="dragging() ? 'upload' : icon()" [size]="22" />
5592
+ </span>
5593
+ <p class="text-sm font-semibold text-ink" [id]="zoneId + '-label'">
5594
+ {{ dragging() ? dropLabel() : label() }}
5595
+ </p>
5596
+ @if (hint()) {
5597
+ <p class="text-xs text-ink-faint" [id]="zoneId + '-hint'">{{ hint() }}</p>
5598
+ }
5599
+ <button
5600
+ type="button"
5601
+ class="mt-1 inline-flex h-9 items-center gap-1.5 rounded-control border border-border bg-surface px-3.5 text-sm font-semibold text-ink shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:cursor-not-allowed disabled:opacity-60"
5602
+ [disabled]="disabled()"
5603
+ [attr.aria-describedby]="describedBy()"
5604
+ (click)="browse($event)"
5605
+ >
5606
+ <sds-icon name="upload" [size]="16" />
5607
+ {{ browseLabel() }}
5608
+ </button>
5609
+ <input
5610
+ #fileInput
5611
+ type="file"
5612
+ class="sr-only"
5613
+ tabindex="-1"
5614
+ aria-hidden="true"
5615
+ [accept]="accept()"
5616
+ [multiple]="multiple()"
5617
+ [disabled]="disabled()"
5618
+ (change)="onInputChange($event)"
5619
+ />
5620
+ </div>
5621
+
5622
+ <div aria-live="polite" aria-atomic="true">
5623
+ @if (errors().length) {
5624
+ <ul [id]="zoneId + '-errors'" class="mt-2 space-y-1">
5625
+ @for (err of errors(); track $index) {
5626
+ <li class="flex items-start gap-1.5 text-xs font-medium text-danger">
5627
+ <sds-icon name="alert-circle" [size]="14" class="mt-px shrink-0" />
5628
+ <span>{{ err }}</span>
5629
+ </li>
5630
+ }
5631
+ </ul>
5632
+ }
5633
+ </div>
5634
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5635
+ }
5636
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsFileDrop, decorators: [{
5637
+ type: Component,
5638
+ args: [{
5639
+ selector: 'sds-file-drop',
5640
+ changeDetection: ChangeDetectionStrategy.OnPush,
5641
+ imports: [SdsIcon],
5642
+ host: { class: 'block' },
5643
+ template: `
5644
+ <div
5645
+ [class]="zoneClass()"
5646
+ [attr.aria-disabled]="disabled() ? 'true' : null"
5647
+ [attr.data-dragging]="dragging() ? 'true' : null"
5648
+ (click)="onZoneClick($event)"
5649
+ (dragenter)="onDragEnter($event)"
5650
+ (dragover)="onDragOver($event)"
5651
+ (dragleave)="onDragLeave($event)"
5652
+ (drop)="onDrop($event)"
5653
+ >
5654
+ <span
5655
+ class="grid h-11 w-11 place-items-center rounded-full bg-surface text-ink-soft shadow-sm ring-1 ring-border"
5656
+ aria-hidden="true"
5657
+ >
5658
+ <sds-icon [name]="dragging() ? 'upload' : icon()" [size]="22" />
5659
+ </span>
5660
+ <p class="text-sm font-semibold text-ink" [id]="zoneId + '-label'">
5661
+ {{ dragging() ? dropLabel() : label() }}
5662
+ </p>
5663
+ @if (hint()) {
5664
+ <p class="text-xs text-ink-faint" [id]="zoneId + '-hint'">{{ hint() }}</p>
5665
+ }
5666
+ <button
5667
+ type="button"
5668
+ class="mt-1 inline-flex h-9 items-center gap-1.5 rounded-control border border-border bg-surface px-3.5 text-sm font-semibold text-ink shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:cursor-not-allowed disabled:opacity-60"
5669
+ [disabled]="disabled()"
5670
+ [attr.aria-describedby]="describedBy()"
5671
+ (click)="browse($event)"
5672
+ >
5673
+ <sds-icon name="upload" [size]="16" />
5674
+ {{ browseLabel() }}
5675
+ </button>
5676
+ <input
5677
+ #fileInput
5678
+ type="file"
5679
+ class="sr-only"
5680
+ tabindex="-1"
5681
+ aria-hidden="true"
5682
+ [accept]="accept()"
5683
+ [multiple]="multiple()"
5684
+ [disabled]="disabled()"
5685
+ (change)="onInputChange($event)"
5686
+ />
5687
+ </div>
5688
+
5689
+ <div aria-live="polite" aria-atomic="true">
5690
+ @if (errors().length) {
5691
+ <ul [id]="zoneId + '-errors'" class="mt-2 space-y-1">
5692
+ @for (err of errors(); track $index) {
5693
+ <li class="flex items-start gap-1.5 text-xs font-medium text-danger">
5694
+ <sds-icon name="alert-circle" [size]="14" class="mt-px shrink-0" />
5695
+ <span>{{ err }}</span>
5696
+ </li>
5697
+ }
5698
+ </ul>
5699
+ }
5700
+ </div>
5701
+ `,
5702
+ }]
5703
+ }], propDecorators: { fileInput: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], maxSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxSize", required: false }] }], maxFiles: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFiles", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], dropLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropLabel", required: false }] }], browseLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "browseLabel", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], wrongTypeMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "wrongTypeMessage", required: false }] }], tooLargeMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooLargeMessage", required: false }] }], tooManyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooManyMessage", required: false }] }], files: [{ type: i0.Output, args: ["files"] }], rejected: [{ type: i0.Output, args: ["rejected"] }] } });
5704
+
5705
+ let nextId$8 = 0;
5706
+ /**
5707
+ * Crops an image to a FIXED aspect ratio (e.g. the shop's card ratio): the frame is fixed and the image
5708
+ * moves and zooms behind it, always covering the frame, so every crop is valid.
5709
+ *
5710
+ * - Pointer: drag to move; wheel or pinch to zoom around the pointer; a zoom slider and ± buttons.
5711
+ * - Keyboard (focus the frame): arrow keys nudge the image (Shift = 10×), `+`/`-` zoom, `0` resets.
5712
+ * - `(cropChange)` emits the crop in the source image's natural pixels after every change;
5713
+ * {@link toBlob} renders it through a canvas (optionally resized) for upload.
5714
+ *
5715
+ * `src` takes a URL or a Blob/File (an object URL is managed for you). For a cross-origin URL the server
5716
+ * must send CORS headers and [crossOrigin] must be set, or the canvas is tainted and toBlob() rejects.
5717
+ *
5718
+ * @example
5719
+ * <sds-image-cropper [src]="file" [aspectRatio]="4 / 3" (cropChange)="crop = $event" #cropper />
5720
+ * <sds-button (click)="upload(await cropper.toBlob({ width: 1600 }))">Save</sds-button>
5721
+ */
5722
+ class SdsImageCropper {
5723
+ helpId = `sds-image-cropper-${nextId$8++}-help`;
5724
+ viewport = viewChild('viewport', ...(ngDevMode ? [{ debugName: "viewport" }] : []));
5725
+ /** Image to crop: a URL, or a Blob/File. */
5726
+ src = input(null, ...(ngDevMode ? [{ debugName: "src" }] : []));
5727
+ /** Frame width ÷ height, e.g. `1`, `4 / 3`, `16 / 9`. */
5728
+ aspectRatio = input(1, ...(ngDevMode ? [{ debugName: "aspectRatio", transform: numberAttribute }] : [{ transform: numberAttribute }]));
5729
+ /** Largest zoom factor relative to "cover". */
5730
+ maxZoom = input(4, ...(ngDevMode ? [{ debugName: "maxZoom", transform: numberAttribute }] : [{ transform: numberAttribute }]));
5731
+ /** Zoom change per button press / key press. */
5732
+ zoomStep = input(0.1, ...(ngDevMode ? [{ debugName: "zoomStep", transform: numberAttribute }] : [{ transform: numberAttribute }]));
5733
+ /** Visual mask only (the output is always the rectangle). */
5734
+ shape = input('rect', ...(ngDevMode ? [{ debugName: "shape" }] : []));
5735
+ /** `crossorigin` for URL sources, needed for toBlob() on a cross-origin image. */
5736
+ crossOrigin = input(null, ...(ngDevMode ? [{ debugName: "crossOrigin" }] : []));
5737
+ label = input('Crop area', ...(ngDevMode ? [{ debugName: "label" }] : []));
5738
+ roleDescription = input('image cropper', ...(ngDevMode ? [{ debugName: "roleDescription" }] : []));
5739
+ instructions = input('Drag, or use the arrow keys, to move the image. Use plus and minus to zoom, 0 to reset.', ...(ngDevMode ? [{ debugName: "instructions" }] : []));
5740
+ zoomLabel = input('Zoom', ...(ngDevMode ? [{ debugName: "zoomLabel" }] : []));
5741
+ zoomInLabel = input('Zoom in', ...(ngDevMode ? [{ debugName: "zoomInLabel" }] : []));
5742
+ zoomOutLabel = input('Zoom out', ...(ngDevMode ? [{ debugName: "zoomOutLabel" }] : []));
5743
+ /** The crop, in natural pixels, after every change. */
5744
+ cropChange = output();
5745
+ /** The image loaded; natural size. */
5746
+ imageLoad = output();
5747
+ /** The image failed to load. */
5748
+ imageError = output();
5749
+ objectUrl = signal(null, ...(ngDevMode ? [{ debugName: "objectUrl" }] : []));
5750
+ ownedUrl = null;
5751
+ imgEl = null;
5752
+ loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : []));
5753
+ naturalW = signal(0, ...(ngDevMode ? [{ debugName: "naturalW" }] : []));
5754
+ naturalH = signal(0, ...(ngDevMode ? [{ debugName: "naturalH" }] : []));
5755
+ /** Frame size in CSS px (tracked with a ResizeObserver). */
5756
+ frameW = signal(0, ...(ngDevMode ? [{ debugName: "frameW" }] : []));
5757
+ frameH = signal(0, ...(ngDevMode ? [{ debugName: "frameH" }] : []));
5758
+ zoom = signal(1, ...(ngDevMode ? [{ debugName: "zoom" }] : []));
5759
+ tx = signal(0, ...(ngDevMode ? [{ debugName: "tx" }] : []));
5760
+ ty = signal(0, ...(ngDevMode ? [{ debugName: "ty" }] : []));
5761
+ dragging = signal(false, ...(ngDevMode ? [{ debugName: "dragging" }] : []));
5762
+ /** Scale that makes the image just cover the frame (zoom 1). */
5763
+ coverScale = computed(() => {
5764
+ const w = this.naturalW();
5765
+ const h = this.naturalH();
5766
+ if (!w || !h || !this.frameW() || !this.frameH())
5767
+ return 1;
5768
+ return Math.max(this.frameW() / w, this.frameH() / h);
5769
+ }, ...(ngDevMode ? [{ debugName: "coverScale" }] : []));
5770
+ scale = computed(() => this.coverScale() * this.zoom(), ...(ngDevMode ? [{ debugName: "scale" }] : []));
5771
+ displayW = computed(() => this.naturalW() * this.scale(), ...(ngDevMode ? [{ debugName: "displayW" }] : []));
5772
+ displayH = computed(() => this.naturalH() * this.scale(), ...(ngDevMode ? [{ debugName: "displayH" }] : []));
5773
+ zoomText = computed(() => `${Math.round(this.zoom() * 100)}%`, ...(ngDevMode ? [{ debugName: "zoomText" }] : []));
5774
+ /** The current crop in natural pixels. */
5775
+ crop = computed(() => {
5776
+ const s = this.scale();
5777
+ if (!this.loaded() || !s)
5778
+ return { x: 0, y: 0, width: 0, height: 0 };
5779
+ const x = -this.tx() / s;
5780
+ const y = -this.ty() / s;
5781
+ const width = Math.min(this.naturalW(), this.frameW() / s);
5782
+ const height = Math.min(this.naturalH(), this.frameH() / s);
5783
+ return {
5784
+ x: Math.max(0, Math.round(x)),
5785
+ y: Math.max(0, Math.round(y)),
5786
+ width: Math.round(width),
5787
+ height: Math.round(height),
5788
+ };
5789
+ }, ...(ngDevMode ? [{ debugName: "crop" }] : []));
5790
+ resizeObserver = null;
5791
+ pointers = new Map();
5792
+ lastPinch = 0;
5793
+ constructor() {
5794
+ // Source → object URL (for Blobs), reset state.
5795
+ effect(() => {
5796
+ const src = this.src();
5797
+ untracked(() => {
5798
+ this.revoke();
5799
+ this.loaded.set(false);
5800
+ if (src instanceof Blob) {
5801
+ this.ownedUrl = URL.createObjectURL(src);
5802
+ this.objectUrl.set(this.ownedUrl);
5803
+ }
5804
+ else {
5805
+ this.objectUrl.set(src || null);
5806
+ }
5807
+ });
5808
+ });
5809
+ // Track the frame size; keep the image covering it (and the crop centre stable) on resize.
5810
+ effect((onCleanup) => {
5811
+ const el = this.viewport()?.nativeElement;
5812
+ if (!el || typeof ResizeObserver === 'undefined')
5813
+ return;
5814
+ const ro = new ResizeObserver(() => this.measureFrame());
5815
+ ro.observe(el);
5816
+ this.resizeObserver = ro;
5817
+ onCleanup(() => ro.disconnect());
5818
+ });
5819
+ }
5820
+ ngOnDestroy() {
5821
+ this.resizeObserver?.disconnect();
5822
+ this.revoke();
5823
+ }
5824
+ // --- loading -------------------------------------------------------------------
5825
+ onLoad(event) {
5826
+ const img = event.target;
5827
+ this.imgEl = img;
5828
+ this.naturalW.set(img.naturalWidth);
5829
+ this.naturalH.set(img.naturalHeight);
5830
+ this.measureFrame();
5831
+ this.loaded.set(true);
5832
+ this.reset();
5833
+ this.imageLoad.emit({ width: img.naturalWidth, height: img.naturalHeight });
5834
+ }
5835
+ onError() {
5836
+ this.loaded.set(false);
5837
+ this.imageError.emit();
5838
+ }
5839
+ revoke() {
5840
+ if (this.ownedUrl)
5841
+ URL.revokeObjectURL(this.ownedUrl);
5842
+ this.ownedUrl = null;
5843
+ }
5844
+ measureFrame() {
5845
+ const el = this.viewport()?.nativeElement;
5846
+ if (!el)
5847
+ return;
5848
+ const prevW = this.frameW();
5849
+ const w = el.clientWidth;
5850
+ const h = el.clientHeight || w / (this.aspectRatio() || 1);
5851
+ if (w === prevW && h === this.frameH())
5852
+ return;
5853
+ // Keep the same point of the image under the frame centre across a resize.
5854
+ const s = this.scale();
5855
+ const cx = s ? (this.frameW() / 2 - this.tx()) / s : 0;
5856
+ const cy = s ? (this.frameH() / 2 - this.ty()) / s : 0;
5857
+ this.frameW.set(w);
5858
+ this.frameH.set(h);
5859
+ if (this.loaded() && prevW) {
5860
+ const ns = this.scale();
5861
+ this.setOffset(w / 2 - cx * ns, h / 2 - cy * ns);
5862
+ this.emit();
5863
+ }
5864
+ }
5865
+ // --- geometry ------------------------------------------------------------------
5866
+ /** Centre the image at zoom 1. */
5867
+ reset() {
5868
+ this.zoom.set(1);
5869
+ this.setOffset((this.frameW() - this.displayW()) / 2, (this.frameH() - this.displayH()) / 2);
5870
+ this.emit();
5871
+ }
5872
+ setOffset(x, y) {
5873
+ const minX = Math.min(0, this.frameW() - this.displayW());
5874
+ const minY = Math.min(0, this.frameH() - this.displayH());
5875
+ this.tx.set(Math.min(0, Math.max(minX, x)));
5876
+ this.ty.set(Math.min(0, Math.max(minY, y)));
5877
+ }
5878
+ /** Zoom to `next`, keeping the frame point (px, py) fixed (defaults to the centre). */
5879
+ zoomTo(next, px = this.frameW() / 2, py = this.frameH() / 2) {
5880
+ const z = Math.min(this.maxZoom(), Math.max(1, next));
5881
+ const s = this.scale();
5882
+ const ix = (px - this.tx()) / s;
5883
+ const iy = (py - this.ty()) / s;
5884
+ this.zoom.set(z);
5885
+ const ns = this.scale();
5886
+ this.setOffset(px - ix * ns, py - iy * ns);
5887
+ this.emit();
5888
+ }
5889
+ /** Zoom in (+) or out (−) by `delta`. */
5890
+ zoomBy(delta) {
5891
+ if (!this.loaded())
5892
+ return;
5893
+ this.zoomTo(this.zoom() + delta);
5894
+ }
5895
+ emit() {
5896
+ if (this.loaded())
5897
+ this.cropChange.emit(this.crop());
5898
+ }
5899
+ // --- pointer ---------------------------------------------------------------------
5900
+ onPointerDown(event) {
5901
+ if (!this.loaded())
5902
+ return;
5903
+ event.currentTarget.setPointerCapture?.(event.pointerId);
5904
+ this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
5905
+ this.dragging.set(true);
5906
+ if (this.pointers.size === 2)
5907
+ this.lastPinch = this.pinchDistance();
5908
+ }
5909
+ onPointerMove(event) {
5910
+ const prev = this.pointers.get(event.pointerId);
5911
+ if (!prev)
5912
+ return;
5913
+ const cur = { x: event.clientX, y: event.clientY };
5914
+ this.pointers.set(event.pointerId, cur);
5915
+ if (this.pointers.size >= 2) {
5916
+ const d = this.pinchDistance();
5917
+ if (this.lastPinch > 0) {
5918
+ const rect = this.viewport().nativeElement.getBoundingClientRect();
5919
+ const pts = [...this.pointers.values()];
5920
+ const mx = (pts[0].x + pts[1].x) / 2 - rect.left;
5921
+ const my = (pts[0].y + pts[1].y) / 2 - rect.top;
5922
+ this.zoomTo(this.zoom() * (d / this.lastPinch), mx, my);
5923
+ }
5924
+ this.lastPinch = d;
5925
+ return;
5926
+ }
5927
+ this.setOffset(this.tx() + (cur.x - prev.x), this.ty() + (cur.y - prev.y));
5928
+ }
5929
+ onPointerUp(event) {
5930
+ if (!this.pointers.delete(event.pointerId))
5931
+ return;
5932
+ if (this.pointers.size < 2)
5933
+ this.lastPinch = 0;
5934
+ if (this.pointers.size === 0) {
5935
+ this.dragging.set(false);
5936
+ this.emit();
5937
+ }
5938
+ }
5939
+ pinchDistance() {
5940
+ const [a, b] = [...this.pointers.values()];
5941
+ return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
5942
+ }
5943
+ onWheel(event) {
5944
+ if (!this.loaded())
5945
+ return;
5946
+ event.preventDefault();
5947
+ const rect = this.viewport().nativeElement.getBoundingClientRect();
5948
+ const factor = Math.exp(-event.deltaY * 0.0015);
5949
+ this.zoomTo(this.zoom() * factor, event.clientX - rect.left, event.clientY - rect.top);
5950
+ }
5951
+ onSlider(event) {
5952
+ this.zoomTo(Number(event.target.value));
5953
+ }
5954
+ // --- keyboard ---------------------------------------------------------------------
5955
+ onKeydown(event) {
5956
+ if (!this.loaded())
5957
+ return;
5958
+ const step = event.shiftKey ? 10 : 1;
5959
+ let handled = true;
5960
+ switch (event.key) {
5961
+ // Arrow keys move the IMAGE in that direction under the fixed frame.
5962
+ case 'ArrowLeft':
5963
+ this.setOffset(this.tx() - step, this.ty());
5964
+ break;
5965
+ case 'ArrowRight':
5966
+ this.setOffset(this.tx() + step, this.ty());
5967
+ break;
5968
+ case 'ArrowUp':
5969
+ this.setOffset(this.tx(), this.ty() - step);
5970
+ break;
5971
+ case 'ArrowDown':
5972
+ this.setOffset(this.tx(), this.ty() + step);
5973
+ break;
5974
+ case '+':
5975
+ case '=':
5976
+ this.zoomBy(this.zoomStep());
5977
+ return event.preventDefault();
5978
+ case '-':
5979
+ case '_':
5980
+ this.zoomBy(-this.zoomStep());
5981
+ return event.preventDefault();
5982
+ case '0':
5983
+ this.reset();
5984
+ return event.preventDefault();
5985
+ default:
5986
+ handled = false;
5987
+ }
5988
+ if (handled) {
5989
+ event.preventDefault();
5990
+ this.emit();
5991
+ }
5992
+ }
5993
+ // --- output -------------------------------------------------------------------------
5994
+ /**
5995
+ * Render the current crop to a canvas (natural resolution, or scaled to `width`). Returns null
5996
+ * before an image has loaded.
5997
+ */
5998
+ toCanvas(width) {
5999
+ const img = this.imgEl;
6000
+ const c = this.crop();
6001
+ if (!img || !this.loaded() || !c.width || !c.height)
6002
+ return null;
6003
+ const outW = Math.max(1, Math.round(width ?? c.width));
6004
+ const outH = Math.max(1, Math.round(outW / (this.aspectRatio() || c.width / c.height)));
6005
+ const canvas = document.createElement('canvas');
6006
+ canvas.width = outW;
6007
+ canvas.height = outH;
6008
+ const ctx = canvas.getContext('2d');
6009
+ if (!ctx)
6010
+ return null;
6011
+ ctx.imageSmoothingQuality = 'high';
6012
+ ctx.drawImage(img, c.x, c.y, c.width, c.height, 0, 0, outW, outH);
6013
+ return canvas;
6014
+ }
6015
+ /** Encode the current crop as a Blob (JPEG 0.9 by default). */
6016
+ toBlob(options = {}) {
6017
+ const canvas = this.toCanvas(options.width);
6018
+ if (!canvas)
6019
+ return Promise.resolve(null);
6020
+ return new Promise((resolve, reject) => {
6021
+ try {
6022
+ canvas.toBlob((b) => resolve(b), options.type ?? 'image/jpeg', options.quality ?? 0.9);
6023
+ }
6024
+ catch (e) {
6025
+ reject(e);
6026
+ }
6027
+ });
6028
+ }
6029
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsImageCropper, deps: [], target: i0.ɵɵFactoryTarget.Component });
6030
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsImageCropper, isStandalone: true, selector: "sds-image-cropper", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, zoomStep: { classPropertyName: "zoomStep", publicName: "zoomStep", isSignal: true, isRequired: false, transformFunction: null }, shape: { classPropertyName: "shape", publicName: "shape", isSignal: true, isRequired: false, transformFunction: null }, crossOrigin: { classPropertyName: "crossOrigin", publicName: "crossOrigin", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, roleDescription: { classPropertyName: "roleDescription", publicName: "roleDescription", isSignal: true, isRequired: false, transformFunction: null }, instructions: { classPropertyName: "instructions", publicName: "instructions", isSignal: true, isRequired: false, transformFunction: null }, zoomLabel: { classPropertyName: "zoomLabel", publicName: "zoomLabel", isSignal: true, isRequired: false, transformFunction: null }, zoomInLabel: { classPropertyName: "zoomInLabel", publicName: "zoomInLabel", isSignal: true, isRequired: false, transformFunction: null }, zoomOutLabel: { classPropertyName: "zoomOutLabel", publicName: "zoomOutLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cropChange: "cropChange", imageLoad: "imageLoad", imageError: "imageError" }, host: { classAttribute: "block" }, viewQueries: [{ propertyName: "viewport", first: true, predicate: ["viewport"], descendants: true, isSignal: true }], ngImport: i0, template: `
6031
+ <div
6032
+ #viewport
6033
+ class="relative w-full touch-none select-none overflow-hidden rounded-card bg-ink/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
6034
+ [class.cursor-grab]="loaded() && !dragging()"
6035
+ [class.cursor-grabbing]="dragging()"
6036
+ [style.aspect-ratio]="aspectRatio()"
6037
+ role="application"
6038
+ [attr.aria-roledescription]="roleDescription()"
6039
+ [attr.aria-label]="label()"
6040
+ [attr.aria-describedby]="helpId"
6041
+ tabindex="0"
6042
+ (pointerdown)="onPointerDown($event)"
6043
+ (pointermove)="onPointerMove($event)"
6044
+ (pointerup)="onPointerUp($event)"
6045
+ (pointercancel)="onPointerUp($event)"
6046
+ (wheel)="onWheel($event)"
6047
+ (keydown)="onKeydown($event)"
6048
+ >
6049
+ @if (objectUrl()) {
6050
+ <img
6051
+ [src]="objectUrl()"
6052
+ [attr.crossorigin]="crossOrigin()"
6053
+ alt=""
6054
+ draggable="false"
6055
+ class="pointer-events-none absolute left-0 top-0 max-w-none origin-top-left"
6056
+ [class.invisible]="!loaded()"
6057
+ [style.width.px]="displayW()"
6058
+ [style.height.px]="displayH()"
6059
+ [style.transform]="'translate3d(' + tx() + 'px,' + ty() + 'px,0)'"
6060
+ (load)="onLoad($event)"
6061
+ (error)="onError()"
6062
+ />
2962
6063
  }
2963
- <ng-content />
2964
- </span>
2965
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
6064
+ @if (shape() === 'round') {
6065
+ <div
6066
+ class="pointer-events-none absolute inset-0 rounded-full shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]"
6067
+ aria-hidden="true"
6068
+ ></div>
6069
+ }
6070
+ <!-- Rule-of-thirds guides. -->
6071
+ <div class="pointer-events-none absolute inset-0" aria-hidden="true">
6072
+ <div class="absolute inset-y-0 left-1/3 w-px bg-white/30"></div>
6073
+ <div class="absolute inset-y-0 left-2/3 w-px bg-white/30"></div>
6074
+ <div class="absolute inset-x-0 top-1/3 h-px bg-white/30"></div>
6075
+ <div class="absolute inset-x-0 top-2/3 h-px bg-white/30"></div>
6076
+ </div>
6077
+ @if (!loaded()) {
6078
+ <div class="absolute inset-0 grid place-items-center text-white/70" aria-hidden="true">
6079
+ <sds-icon name="image" [size]="32" />
6080
+ </div>
6081
+ }
6082
+ </div>
6083
+ <p [id]="helpId" class="sr-only">{{ instructions() }}</p>
6084
+
6085
+ <div class="mt-3 flex items-center gap-2">
6086
+ <button
6087
+ type="button"
6088
+ class="grid h-9 w-9 shrink-0 place-items-center rounded-control text-ink-soft hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-40"
6089
+ [attr.aria-label]="zoomOutLabel()"
6090
+ [disabled]="!loaded() || zoom() <= 1"
6091
+ (click)="zoomBy(-zoomStep())"
6092
+ >
6093
+ <sds-icon name="zoom-out" [size]="18" />
6094
+ </button>
6095
+ <input
6096
+ type="range"
6097
+ class="h-2 min-w-0 flex-1 cursor-pointer accent-brand-600"
6098
+ min="1"
6099
+ [max]="maxZoom()"
6100
+ step="0.01"
6101
+ [value]="zoom()"
6102
+ [disabled]="!loaded()"
6103
+ [attr.aria-label]="zoomLabel()"
6104
+ [attr.aria-valuetext]="zoomText()"
6105
+ (input)="onSlider($event)"
6106
+ />
6107
+ <button
6108
+ type="button"
6109
+ class="grid h-9 w-9 shrink-0 place-items-center rounded-control text-ink-soft hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-40"
6110
+ [attr.aria-label]="zoomInLabel()"
6111
+ [disabled]="!loaded() || zoom() >= maxZoom()"
6112
+ (click)="zoomBy(zoomStep())"
6113
+ >
6114
+ <sds-icon name="zoom-in" [size]="18" />
6115
+ </button>
6116
+ </div>
6117
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2966
6118
  }
2967
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsBadge, decorators: [{
6119
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsImageCropper, decorators: [{
2968
6120
  type: Component,
2969
6121
  args: [{
2970
- selector: 'sds-badge',
6122
+ selector: 'sds-image-cropper',
2971
6123
  changeDetection: ChangeDetectionStrategy.OnPush,
2972
- host: { class: 'inline-flex' },
6124
+ imports: [SdsIcon],
6125
+ host: { class: 'block' },
2973
6126
  template: `
2974
- <span
2975
- class="inline-flex items-center gap-1.5 rounded-full border font-semibold uppercase tracking-wide"
2976
- [class]="classes()"
6127
+ <div
6128
+ #viewport
6129
+ class="relative w-full touch-none select-none overflow-hidden rounded-card bg-ink/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
6130
+ [class.cursor-grab]="loaded() && !dragging()"
6131
+ [class.cursor-grabbing]="dragging()"
6132
+ [style.aspect-ratio]="aspectRatio()"
6133
+ role="application"
6134
+ [attr.aria-roledescription]="roleDescription()"
6135
+ [attr.aria-label]="label()"
6136
+ [attr.aria-describedby]="helpId"
6137
+ tabindex="0"
6138
+ (pointerdown)="onPointerDown($event)"
6139
+ (pointermove)="onPointerMove($event)"
6140
+ (pointerup)="onPointerUp($event)"
6141
+ (pointercancel)="onPointerUp($event)"
6142
+ (wheel)="onWheel($event)"
6143
+ (keydown)="onKeydown($event)"
2977
6144
  >
2978
- @if (dot()) {
2979
- <span class="h-1.5 w-1.5 rounded-full" [class]="dotClass()"></span>
6145
+ @if (objectUrl()) {
6146
+ <img
6147
+ [src]="objectUrl()"
6148
+ [attr.crossorigin]="crossOrigin()"
6149
+ alt=""
6150
+ draggable="false"
6151
+ class="pointer-events-none absolute left-0 top-0 max-w-none origin-top-left"
6152
+ [class.invisible]="!loaded()"
6153
+ [style.width.px]="displayW()"
6154
+ [style.height.px]="displayH()"
6155
+ [style.transform]="'translate3d(' + tx() + 'px,' + ty() + 'px,0)'"
6156
+ (load)="onLoad($event)"
6157
+ (error)="onError()"
6158
+ />
2980
6159
  }
2981
- <ng-content />
2982
- </span>
2983
- `,
2984
- }]
2985
- }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], dot: [{ type: i0.Input, args: [{ isSignal: true, alias: "dot", required: false }] }] } });
6160
+ @if (shape() === 'round') {
6161
+ <div
6162
+ class="pointer-events-none absolute inset-0 rounded-full shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]"
6163
+ aria-hidden="true"
6164
+ ></div>
6165
+ }
6166
+ <!-- Rule-of-thirds guides. -->
6167
+ <div class="pointer-events-none absolute inset-0" aria-hidden="true">
6168
+ <div class="absolute inset-y-0 left-1/3 w-px bg-white/30"></div>
6169
+ <div class="absolute inset-y-0 left-2/3 w-px bg-white/30"></div>
6170
+ <div class="absolute inset-x-0 top-1/3 h-px bg-white/30"></div>
6171
+ <div class="absolute inset-x-0 top-2/3 h-px bg-white/30"></div>
6172
+ </div>
6173
+ @if (!loaded()) {
6174
+ <div class="absolute inset-0 grid place-items-center text-white/70" aria-hidden="true">
6175
+ <sds-icon name="image" [size]="32" />
6176
+ </div>
6177
+ }
6178
+ </div>
6179
+ <p [id]="helpId" class="sr-only">{{ instructions() }}</p>
2986
6180
 
2987
- const VARIANTS = {
2988
- elevated: 'bg-surface border border-border shadow-sm',
2989
- outlined: 'bg-surface border border-border',
2990
- subtle: 'bg-muted border border-border',
2991
- };
2992
- /**
2993
- * Generic surface container. Set [interactive]="true" for hover affordance
2994
- * (used by the dashboard action cards).
2995
- */
2996
- class SdsCard {
2997
- variant = input('elevated', ...(ngDevMode ? [{ debugName: "variant" }] : []));
2998
- /** Inner padding utility (override with e.g. "p-4"). */
2999
- padding = input('p-5', ...(ngDevMode ? [{ debugName: "padding" }] : []));
3000
- interactive = input(false, ...(ngDevMode ? [{ debugName: "interactive", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
3001
- hostClass = computed(() => {
3002
- const parts = ['block rounded-card', VARIANTS[this.variant()], this.padding()];
3003
- if (this.interactive()) {
3004
- parts.push('cursor-pointer transition-all duration-150 hover:border-brand-300 hover:shadow-md');
3005
- }
3006
- return parts.join(' ');
3007
- }, ...(ngDevMode ? [{ debugName: "hostClass" }] : []));
3008
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsCard, deps: [], target: i0.ɵɵFactoryTarget.Component });
3009
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "20.3.25", type: SdsCard, isStandalone: true, selector: "sds-card", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "hostClass()" } }, ngImport: i0, template: `<ng-content />`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3010
- }
3011
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsCard, decorators: [{
3012
- type: Component,
3013
- args: [{
3014
- selector: 'sds-card',
3015
- changeDetection: ChangeDetectionStrategy.OnPush,
3016
- host: { '[class]': 'hostClass()' },
3017
- template: `<ng-content />`,
6181
+ <div class="mt-3 flex items-center gap-2">
6182
+ <button
6183
+ type="button"
6184
+ class="grid h-9 w-9 shrink-0 place-items-center rounded-control text-ink-soft hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-40"
6185
+ [attr.aria-label]="zoomOutLabel()"
6186
+ [disabled]="!loaded() || zoom() <= 1"
6187
+ (click)="zoomBy(-zoomStep())"
6188
+ >
6189
+ <sds-icon name="zoom-out" [size]="18" />
6190
+ </button>
6191
+ <input
6192
+ type="range"
6193
+ class="h-2 min-w-0 flex-1 cursor-pointer accent-brand-600"
6194
+ min="1"
6195
+ [max]="maxZoom()"
6196
+ step="0.01"
6197
+ [value]="zoom()"
6198
+ [disabled]="!loaded()"
6199
+ [attr.aria-label]="zoomLabel()"
6200
+ [attr.aria-valuetext]="zoomText()"
6201
+ (input)="onSlider($event)"
6202
+ />
6203
+ <button
6204
+ type="button"
6205
+ class="grid h-9 w-9 shrink-0 place-items-center rounded-control text-ink-soft hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-40"
6206
+ [attr.aria-label]="zoomInLabel()"
6207
+ [disabled]="!loaded() || zoom() >= maxZoom()"
6208
+ (click)="zoomBy(zoomStep())"
6209
+ >
6210
+ <sds-icon name="zoom-in" [size]="18" />
6211
+ </button>
6212
+ </div>
6213
+ `,
3018
6214
  }]
3019
- }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }] } });
6215
+ }], ctorParameters: () => [], propDecorators: { viewport: [{ type: i0.ViewChild, args: ['viewport', { isSignal: true }] }], src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], zoomStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomStep", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], crossOrigin: [{ type: i0.Input, args: [{ isSignal: true, alias: "crossOrigin", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], roleDescription: [{ type: i0.Input, args: [{ isSignal: true, alias: "roleDescription", required: false }] }], instructions: [{ type: i0.Input, args: [{ isSignal: true, alias: "instructions", required: false }] }], zoomLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomLabel", required: false }] }], zoomInLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomInLabel", required: false }] }], zoomOutLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomOutLabel", required: false }] }], cropChange: [{ type: i0.Output, args: ["cropChange"] }], imageLoad: [{ type: i0.Output, args: ["imageLoad"] }], imageError: [{ type: i0.Output, args: ["imageError"] }] } });
3020
6216
 
3021
6217
  /** Small, dependency-free date helpers (day-granularity, local time). */
3022
6218
  function startOfDay(d) {
@@ -7855,6 +11051,16 @@ let nextId$1 = 0;
7855
11051
  * every filtered row regardless of pagination (this is an intentional
7856
11052
  * deviation from PrimeNG's per-page select-all).
7857
11053
  *
11054
+ * **Editable grid (opt-in).** Set `editable` and give columns an `editor` (`text` · `number` ·
11055
+ * `currency` · `select`). The table becomes an ARIA grid with one tab stop: arrow keys move between
11056
+ * cells (Home/End, Ctrl+Home/End), Enter or F2 edits (typing a character starts editing with it),
11057
+ * Enter commits and moves down (Shift+Enter up), Tab / Shift+Tab commit and move to the next/previous
11058
+ * editable cell, Escape cancels (and is stopped, so an enclosing drawer stays open), clicking elsewhere
11059
+ * commits. Each changed commit emits `(cellCommit)` — the host applies it to its data (the table never
11060
+ * mutates rows) — and marks the cell dirty until {@link clearDirty}. Errors from `[cellErrors]` show
11061
+ * under the cell with `aria-invalid`. Number/currency cells use sds-input-number (live, locale-aware
11062
+ * formatting: "11,9" → "€11.90"). Everything else — sort, paging, selection — is unchanged.
11063
+ *
7858
11064
  * @example
7859
11065
  * <sds-table [value]="staff" [columns]="cols" dataKey="id"
7860
11066
  * sortable paginator [rows]="10" globalFilter
@@ -7863,6 +11069,11 @@ let nextId$1 = 0;
7863
11069
  * <sds-badge [variant]="row.actif ? 'success' : 'neutral'">{{ value }}</sds-badge>
7864
11070
  * </ng-template>
7865
11071
  * </sds-table>
11072
+ *
11073
+ * @example
11074
+ * <sds-table [value]="drafts()" [columns]="[{ field: 'name', header: 'Name', editor: 'text' },
11075
+ * { field: 'price', header: 'Price', editor: 'currency', align: 'right' }]"
11076
+ * dataKey="id" editable [cellErrors]="errors()" (cellCommit)="apply($event)" />
7866
11077
  */
7867
11078
  class SdsTable {
7868
11079
  tableId = `sds-table-${nextId$1++}`;
@@ -7921,6 +11132,16 @@ class SdsTable {
7921
11132
  resizableColumns = input(false, ...(ngDevMode ? [{ debugName: "resizableColumns", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
7922
11133
  /** Drag a column header to reorder columns. */
7923
11134
  reorderableColumns = input(false, ...(ngDevMode ? [{ debugName: "reorderableColumns", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
11135
+ /** Turn on the editable grid (columns opt in with `editor`). Off: the table is unchanged. */
11136
+ editable = input(false, ...(ngDevMode ? [{ debugName: "editable", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
11137
+ /** Whether a single click (default) or a double click starts editing an editable cell. */
11138
+ editOn = input('click', ...(ngDevMode ? [{ debugName: "editOn" }] : []));
11139
+ /** Per-cell error messages, keyed by row key then field (see {@link SdsTableCellErrors}). */
11140
+ cellErrors = input({}, ...(ngDevMode ? [{ debugName: "cellErrors" }] : []));
11141
+ /** Default locale for number/currency cells (a column's `locale` wins). Empty = the browser's. */
11142
+ locale = input('', ...(ngDevMode ? [{ debugName: "locale" }] : []));
11143
+ /** Spoken marker of an edited (dirty) cell. */
11144
+ dirtyLabel = input('edited', ...(ngDevMode ? [{ debugName: "dirtyLabel" }] : []));
7924
11145
  // --- outputs --------------------------------------------------------------
7925
11146
  sortChange = output();
7926
11147
  pageChange = output();
@@ -7934,6 +11155,8 @@ class SdsTable {
7934
11155
  rowCollapse = output();
7935
11156
  /** Emitted after a drag-reorder with the moved field and its old/new index. */
7936
11157
  colReorder = output();
11158
+ /** Emitted when an edited cell is committed with a CHANGED value. Apply it to your data. */
11159
+ cellCommit = output();
7937
11160
  // --- custom cell templates ------------------------------------------------
7938
11161
  cellTemplates = contentChildren(SdsTableCell, ...(ngDevMode ? [{ debugName: "cellTemplates" }] : []));
7939
11162
  templateMap = computed(() => {
@@ -8186,7 +11409,7 @@ class SdsTable {
8186
11409
  }
8187
11410
  /** Enter/Space on a (single-mode) focusable row toggles its selection. */
8188
11411
  onRowKeydown(event, row) {
8189
- if (this.selectionMode() !== 'single' || this.loading())
11412
+ if (this.selectionMode() !== 'single' || this.loading() || this.editable())
8190
11413
  return;
8191
11414
  event.preventDefault();
8192
11415
  this.onRowClick(row);
@@ -8222,6 +11445,7 @@ class SdsTable {
8222
11445
  }
8223
11446
  // --- paginator ------------------------------------------------------------
8224
11447
  onPageChange(state) {
11448
+ this.editing.set(null);
8225
11449
  this.pageChange.emit(state);
8226
11450
  if (this.lazy())
8227
11451
  this.emitLazy();
@@ -8334,6 +11558,408 @@ class SdsTable {
8334
11558
  return row[k];
8335
11559
  return index;
8336
11560
  }
11561
+ // --- editable grid ----------------------------------------------------------
11562
+ host = inject(ElementRef);
11563
+ injector = inject(Injector);
11564
+ numberEditor = viewChild(SdsInputNumber, ...(ngDevMode ? [{ debugName: "numberEditor" }] : []));
11565
+ /** The roving tab stop (row index in the displayed page, column index). */
11566
+ activeCell = signal({ r: 0, c: 0 }, ...(ngDevMode ? [{ debugName: "activeCell" }] : []));
11567
+ /** The cell being edited, if any. */
11568
+ editing = signal(null, ...(ngDevMode ? [{ debugName: "editing" }] : []));
11569
+ /** The editor's working value. */
11570
+ draft = signal(null, ...(ngDevMode ? [{ debugName: "draft" }] : []));
11571
+ /** Edited cells: row key → fields. */
11572
+ dirty = signal(new Map(), ...(ngDevMode ? [{ debugName: "dirty" }] : []));
11573
+ /** Set while the table moves focus itself, so the editor's blur doesn't commit twice. */
11574
+ suppressBlur = false;
11575
+ /** Active cell clamped to the current page and columns. */
11576
+ tabStop = computed(() => {
11577
+ const rows = this.displayRows().length;
11578
+ const cols = this.effectiveColumns().length;
11579
+ const { r, c } = this.activeCell();
11580
+ return { r: Math.max(0, Math.min(r, rows - 1)), c: Math.max(0, Math.min(c, cols - 1)) };
11581
+ }, ...(ngDevMode ? [{ debugName: "tabStop" }] : []));
11582
+ draftText = computed(() => {
11583
+ const d = this.draft();
11584
+ return d == null ? '' : String(d);
11585
+ }, ...(ngDevMode ? [{ debugName: "draftText" }] : []));
11586
+ isTabStop(r, c) {
11587
+ const t = this.tabStop();
11588
+ return t.r === r && t.c === c;
11589
+ }
11590
+ isEditing(r, c) {
11591
+ const e = this.editing();
11592
+ return !!e && e.r === r && e.c === c;
11593
+ }
11594
+ isCellEditable(row, col) {
11595
+ if (!this.editable() || !col.editor || this.loading())
11596
+ return false;
11597
+ const rule = col.editable;
11598
+ if (rule === undefined)
11599
+ return true;
11600
+ return typeof rule === 'function' ? !!rule(row) : rule;
11601
+ }
11602
+ /** Row key used by dirty tracking, errors and commit events. */
11603
+ rowKeyOf(row) {
11604
+ if (this.dataKey() && row && typeof row === 'object')
11605
+ return row[this.dataKey()];
11606
+ const idx = this.value().indexOf(row);
11607
+ return idx >= 0 ? idx : row;
11608
+ }
11609
+ cellErrorFor(row, field) {
11610
+ const byRow = this.cellErrors()?.[String(this.rowKeyOf(row))];
11611
+ return (byRow && byRow[field]) || null;
11612
+ }
11613
+ cellErrorId(r, c) {
11614
+ return `${this.tableId}-err-${r}-${c}`;
11615
+ }
11616
+ isDirty(row, field) {
11617
+ return this.dirty().get(this.rowKeyOf(row))?.has(field) ?? false;
11618
+ }
11619
+ /** Forget the dirty marks — all of them, or one row's (by row key). */
11620
+ clearDirty(rowKey) {
11621
+ if (rowKey === undefined) {
11622
+ this.dirty.set(new Map());
11623
+ return;
11624
+ }
11625
+ const next = new Map(this.dirty());
11626
+ next.delete(rowKey);
11627
+ this.dirty.set(next);
11628
+ }
11629
+ /** Display text of a cell in view mode: `format`, else the editor's formatting, else the raw value. */
11630
+ displayCell(row, col) {
11631
+ const v = this.getValue(row, col.field);
11632
+ if (col.format)
11633
+ return col.format(v, row);
11634
+ if (v == null || v === '')
11635
+ return '';
11636
+ if ((col.editor === 'currency' || col.editor === 'number') && typeof v === 'number') {
11637
+ try {
11638
+ const opts = col.editor === 'currency'
11639
+ ? { style: 'currency', currency: col.currency ?? 'EUR' }
11640
+ : { style: 'decimal' };
11641
+ if (col.minFractionDigits != null)
11642
+ opts.minimumFractionDigits = col.minFractionDigits;
11643
+ if (col.maxFractionDigits != null)
11644
+ opts.maximumFractionDigits = col.maxFractionDigits;
11645
+ return new Intl.NumberFormat(col.locale ?? (this.locale() || undefined), opts).format(v);
11646
+ }
11647
+ catch {
11648
+ return String(v);
11649
+ }
11650
+ }
11651
+ if (col.editor === 'select') {
11652
+ return col.options?.find((o) => o.value === v)?.label ?? String(v);
11653
+ }
11654
+ return String(v);
11655
+ }
11656
+ gridCellClass(row, col, r, c) {
11657
+ const align = ALIGN[col.align ?? 'left'];
11658
+ const editing = this.isEditing(r, c);
11659
+ // While editing, the cell trades its padding for the editor's own, so the row height barely moves.
11660
+ const pad = editing ? 'px-1 py-1 text-sm' : SIZES[this.size()].cell;
11661
+ const parts = [
11662
+ pad,
11663
+ align,
11664
+ 'relative text-ink align-middle focus:outline-none focus:ring-2 focus:ring-inset focus:ring-brand-500',
11665
+ ];
11666
+ if (!editing && this.isCellEditable(row, col))
11667
+ parts.push('cursor-text hover:bg-muted/60');
11668
+ if (this.cellErrorFor(row, col.field))
11669
+ parts.push('bg-accent-red-soft/50 shadow-[inset_0_0_0_1px_var(--color-danger)]');
11670
+ return parts.join(' ');
11671
+ }
11672
+ onCellFocus(r, c) {
11673
+ const a = this.activeCell();
11674
+ if (a.r !== r || a.c !== c)
11675
+ this.activeCell.set({ r, c });
11676
+ }
11677
+ onCellClick(event, r, c) {
11678
+ if (this.isEditing(r, c)) {
11679
+ event.stopPropagation();
11680
+ return;
11681
+ }
11682
+ this.activeCell.set({ r, c });
11683
+ const row = this.displayRows()[r];
11684
+ const col = this.effectiveColumns()[c];
11685
+ if (this.editOn() === 'click' && row && col && this.isCellEditable(row, col)) {
11686
+ // Keep a click that edits a cell from also toggling single-row selection.
11687
+ event.stopPropagation();
11688
+ this.startEdit(r, c);
11689
+ }
11690
+ }
11691
+ onCellDblClick(event, r, c) {
11692
+ if (this.editOn() !== 'dblclick' || this.isEditing(r, c))
11693
+ return;
11694
+ const row = this.displayRows()[r];
11695
+ const col = this.effectiveColumns()[c];
11696
+ if (row && col && this.isCellEditable(row, col)) {
11697
+ event.stopPropagation();
11698
+ this.startEdit(r, c);
11699
+ }
11700
+ }
11701
+ /**
11702
+ * Start editing a cell of the displayed page (e.g. the first cell of a row you just added).
11703
+ * `field` names the column. Returns false when the cell isn't editable.
11704
+ */
11705
+ editCell(rowIndex, field) {
11706
+ const c = this.effectiveColumns().findIndex((col) => col.field === field);
11707
+ const row = this.displayRows()[rowIndex];
11708
+ const col = this.effectiveColumns()[c];
11709
+ if (c < 0 || !row || !this.isCellEditable(row, col))
11710
+ return false;
11711
+ this.activeCell.set({ r: rowIndex, c });
11712
+ this.startEdit(rowIndex, c);
11713
+ return true;
11714
+ }
11715
+ startEdit(r, c, initialChar) {
11716
+ const row = this.displayRows()[r];
11717
+ const col = this.effectiveColumns()[c];
11718
+ if (!row || !col || !this.isCellEditable(row, col))
11719
+ return;
11720
+ if (this.editing())
11721
+ this.commitEdit('none', false);
11722
+ const current = this.getValue(row, col.field);
11723
+ const numeric = col.editor === 'number' || col.editor === 'currency';
11724
+ this.draft.set(initialChar !== undefined && !numeric ? initialChar : initialChar !== undefined ? null : current);
11725
+ this.activeCell.set({ r, c });
11726
+ this.editing.set({ r, c });
11727
+ afterNextRender(() => this.focusEditor(col, initialChar), { injector: this.injector });
11728
+ }
11729
+ focusEditor(col, initialChar) {
11730
+ const e = this.editing();
11731
+ if (!e)
11732
+ return;
11733
+ const td = this.cellEl(e.r, e.c);
11734
+ if (!td)
11735
+ return;
11736
+ if (col.editor === 'number' || col.editor === 'currency') {
11737
+ const cmp = this.numberEditor();
11738
+ if (!cmp)
11739
+ return;
11740
+ const d = this.draft();
11741
+ cmp.writeValue(typeof d === 'number' ? d : null);
11742
+ cmp.registerOnChange((v) => this.draft.set(v));
11743
+ cmp.focus({ select: initialChar === undefined });
11744
+ if (initialChar !== undefined) {
11745
+ const field = td.querySelector('input');
11746
+ if (field) {
11747
+ field.value = initialChar;
11748
+ field.dispatchEvent(new Event('input', { bubbles: true }));
11749
+ }
11750
+ }
11751
+ return;
11752
+ }
11753
+ const el = td.querySelector('[data-sds-cell-editor]');
11754
+ el?.focus();
11755
+ if (el instanceof HTMLInputElement) {
11756
+ if (initialChar === undefined)
11757
+ el.select();
11758
+ else
11759
+ el.setSelectionRange(el.value.length, el.value.length);
11760
+ }
11761
+ }
11762
+ onTextEditorInput(event) {
11763
+ this.draft.set(event.target.value);
11764
+ }
11765
+ onSelectEditorChange(event, col) {
11766
+ const idx = Number(event.target.value);
11767
+ const opt = col.options?.[idx];
11768
+ this.draft.set(opt ? opt.value : null);
11769
+ }
11770
+ /** Focus left the editor (a click elsewhere): commit, but don't pull focus back. */
11771
+ onEditorBlur() {
11772
+ if (this.suppressBlur || !this.editing())
11773
+ return;
11774
+ // A focus move INSIDE the editor (sds-input-number's own buttons) is not a blur of the cell.
11775
+ setTimeout(() => {
11776
+ const e = this.editing();
11777
+ if (!e || this.suppressBlur)
11778
+ return;
11779
+ const td = this.cellEl(e.r, e.c);
11780
+ if (td && td.contains(document.activeElement) && document.activeElement !== td)
11781
+ return;
11782
+ this.commitEdit('none', false);
11783
+ });
11784
+ }
11785
+ /**
11786
+ * Commit the edit. `move` picks the next active cell; `refocus` puts focus back on the grid (not when
11787
+ * the user clicked away).
11788
+ */
11789
+ commitEdit(move, refocus = true) {
11790
+ const e = this.editing();
11791
+ if (!e)
11792
+ return;
11793
+ // Move focus to the cell first: the number editor clamps/tidies its value on blur.
11794
+ this.suppressBlur = true;
11795
+ if (refocus)
11796
+ this.cellEl(e.r, e.c)?.focus();
11797
+ this.suppressBlur = false;
11798
+ const row = this.displayRows()[e.r];
11799
+ const col = this.effectiveColumns()[e.c];
11800
+ this.editing.set(null);
11801
+ if (row && col) {
11802
+ let value = this.draft();
11803
+ if (col.editor === 'text' || col.editor === undefined)
11804
+ value = value == null ? '' : String(value);
11805
+ const previousValue = this.getValue(row, col.field);
11806
+ const same = value === previousValue ||
11807
+ ((value === '' || value == null) && (previousValue === '' || previousValue == null));
11808
+ if (!same) {
11809
+ const key = this.rowKeyOf(row);
11810
+ const next = new Map(this.dirty());
11811
+ next.set(key, new Set([...(next.get(key) ?? []), col.field]));
11812
+ this.dirty.set(next);
11813
+ this.cellCommit.emit({
11814
+ row,
11815
+ field: col.field,
11816
+ value,
11817
+ previousValue,
11818
+ rowKey: key,
11819
+ rowIndex: this.value().indexOf(row),
11820
+ });
11821
+ }
11822
+ }
11823
+ let target = { r: e.r, c: e.c };
11824
+ if (move === 'down')
11825
+ target = { r: Math.min(e.r + 1, this.displayRows().length - 1), c: e.c };
11826
+ else if (move === 'up')
11827
+ target = { r: Math.max(0, e.r - 1), c: e.c };
11828
+ else if (move === 'next' || move === 'prev')
11829
+ target = this.nextEditable(e.r, e.c, move === 'next' ? 1 : -1) ?? target;
11830
+ this.activeCell.set(target);
11831
+ if (refocus) {
11832
+ if (move === 'next' || move === 'prev') {
11833
+ // Tab keeps the spreadsheet rhythm: land in the next editable cell already editing.
11834
+ if (target.r !== e.r || target.c !== e.c)
11835
+ this.startEdit(target.r, target.c);
11836
+ else
11837
+ this.focusCellSoon(target.r, target.c);
11838
+ }
11839
+ else {
11840
+ this.focusCellSoon(target.r, target.c);
11841
+ }
11842
+ }
11843
+ }
11844
+ cancelEdit() {
11845
+ const e = this.editing();
11846
+ if (!e)
11847
+ return;
11848
+ this.suppressBlur = true;
11849
+ this.cellEl(e.r, e.c)?.focus();
11850
+ this.suppressBlur = false;
11851
+ this.editing.set(null);
11852
+ this.focusCellSoon(e.r, e.c);
11853
+ }
11854
+ /** The next editable cell in reading order (wrapping rows), or null at the grid's edge. */
11855
+ nextEditable(r, c, dir) {
11856
+ const rows = this.displayRows();
11857
+ const cols = this.effectiveColumns();
11858
+ let rr = r;
11859
+ let cc = c;
11860
+ for (let n = 0; n < rows.length * cols.length; n++) {
11861
+ cc += dir;
11862
+ if (cc >= cols.length) {
11863
+ cc = 0;
11864
+ rr++;
11865
+ }
11866
+ else if (cc < 0) {
11867
+ cc = cols.length - 1;
11868
+ rr--;
11869
+ }
11870
+ if (rr < 0 || rr >= rows.length)
11871
+ return null;
11872
+ if (this.isCellEditable(rows[rr], cols[cc]))
11873
+ return { r: rr, c: cc };
11874
+ }
11875
+ return null;
11876
+ }
11877
+ cellEl(r, c) {
11878
+ return this.host.nativeElement.querySelector(`td[data-cell="${r}:${c}"]`);
11879
+ }
11880
+ focusCellSoon(r, c) {
11881
+ afterNextRender(() => this.cellEl(r, c)?.focus(), { injector: this.injector });
11882
+ }
11883
+ onGridKeydown(event) {
11884
+ if (!this.editable() || this.loading())
11885
+ return;
11886
+ const target = event.target;
11887
+ const e = this.editing();
11888
+ // --- editing: Enter / Tab / Escape drive the edit; everything else belongs to the editor.
11889
+ if (e && this.cellEl(e.r, e.c)?.contains(target) && target.tagName !== 'TD') {
11890
+ switch (event.key) {
11891
+ case 'Enter':
11892
+ event.preventDefault();
11893
+ this.commitEdit(event.shiftKey ? 'up' : 'down');
11894
+ return;
11895
+ case 'Tab': {
11896
+ const next = this.nextEditable(e.r, e.c, event.shiftKey ? -1 : 1);
11897
+ if (!next) {
11898
+ // Last editable cell: commit and let Tab leave the grid naturally.
11899
+ this.commitEdit('none', false);
11900
+ return;
11901
+ }
11902
+ event.preventDefault();
11903
+ this.commitEdit(event.shiftKey ? 'prev' : 'next');
11904
+ return;
11905
+ }
11906
+ case 'Escape':
11907
+ event.preventDefault();
11908
+ event.stopPropagation();
11909
+ this.cancelEdit();
11910
+ return;
11911
+ }
11912
+ return;
11913
+ }
11914
+ // --- navigation: only when a grid cell itself has focus.
11915
+ const td = target.closest('td[data-cell]');
11916
+ if (!td || td !== target)
11917
+ return;
11918
+ const [r, c] = (td.dataset['cell'] ?? '0:0').split(':').map(Number);
11919
+ const rows = this.displayRows().length;
11920
+ const cols = this.effectiveColumns().length;
11921
+ let next = null;
11922
+ switch (event.key) {
11923
+ case 'ArrowUp':
11924
+ next = { r: Math.max(0, r - 1), c };
11925
+ break;
11926
+ case 'ArrowDown':
11927
+ next = { r: Math.min(rows - 1, r + 1), c };
11928
+ break;
11929
+ case 'ArrowLeft':
11930
+ next = { r, c: Math.max(0, c - 1) };
11931
+ break;
11932
+ case 'ArrowRight':
11933
+ next = { r, c: Math.min(cols - 1, c + 1) };
11934
+ break;
11935
+ case 'Home':
11936
+ next = event.ctrlKey || event.metaKey ? { r: 0, c: 0 } : { r, c: 0 };
11937
+ break;
11938
+ case 'End':
11939
+ next = event.ctrlKey || event.metaKey ? { r: rows - 1, c: cols - 1 } : { r, c: cols - 1 };
11940
+ break;
11941
+ case 'Enter':
11942
+ case 'F2':
11943
+ event.preventDefault();
11944
+ this.startEdit(r, c);
11945
+ return;
11946
+ default: {
11947
+ const row = this.displayRows()[r];
11948
+ const col = this.effectiveColumns()[c];
11949
+ const printable = event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;
11950
+ if (printable && row && col && col.editor !== 'select' && this.isCellEditable(row, col)) {
11951
+ event.preventDefault();
11952
+ this.startEdit(r, c, event.key);
11953
+ }
11954
+ return;
11955
+ }
11956
+ }
11957
+ event.preventDefault();
11958
+ if (next) {
11959
+ this.activeCell.set(next);
11960
+ this.cellEl(next.r, next.c)?.focus();
11961
+ }
11962
+ }
8337
11963
  // --- styling --------------------------------------------------------------
8338
11964
  headCellClass(col) {
8339
11965
  const align = ALIGN[col?.align ?? 'left'];
@@ -8366,7 +11992,7 @@ class SdsTable {
8366
11992
  : 'border-border bg-surface';
8367
11993
  }
8368
11994
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsTable, deps: [], target: i0.ɵɵFactoryTarget.Component });
8369
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsTable, isStandalone: true, selector: "sds-table", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, dataKey: { classPropertyName: "dataKey", publicName: "dataKey", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, striped: { classPropertyName: "striped", publicName: "striped", isSignal: true, isRequired: false, transformFunction: null }, gridlines: { classPropertyName: "gridlines", publicName: "gridlines", isSignal: true, isRequired: false, transformFunction: null }, hoverable: { classPropertyName: "hoverable", publicName: "hoverable", isSignal: true, isRequired: false, transformFunction: null }, sortable: { classPropertyName: "sortable", publicName: "sortable", isSignal: true, isRequired: false, transformFunction: null }, paginator: { classPropertyName: "paginator", publicName: "paginator", isSignal: true, isRequired: false, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: false, transformFunction: null }, rowsPerPageOptions: { classPropertyName: "rowsPerPageOptions", publicName: "rowsPerPageOptions", isSignal: true, isRequired: false, transformFunction: null }, showCurrentPageReport: { classPropertyName: "showCurrentPageReport", publicName: "showCurrentPageReport", isSignal: true, isRequired: false, transformFunction: null }, currentPageReportTemplate: { classPropertyName: "currentPageReportTemplate", publicName: "currentPageReportTemplate", isSignal: true, isRequired: false, transformFunction: null }, rowsPerPageLabel: { classPropertyName: "rowsPerPageLabel", publicName: "rowsPerPageLabel", isSignal: true, isRequired: false, transformFunction: null }, paginationLabel: { classPropertyName: "paginationLabel", publicName: "paginationLabel", isSignal: true, isRequired: false, transformFunction: null }, firstPageLabel: { classPropertyName: "firstPageLabel", publicName: "firstPageLabel", isSignal: true, isRequired: false, transformFunction: null }, previousPageLabel: { classPropertyName: "previousPageLabel", publicName: "previousPageLabel", isSignal: true, isRequired: false, transformFunction: null }, nextPageLabel: { classPropertyName: "nextPageLabel", publicName: "nextPageLabel", isSignal: true, isRequired: false, transformFunction: null }, lastPageLabel: { classPropertyName: "lastPageLabel", publicName: "lastPageLabel", isSignal: true, isRequired: false, transformFunction: null }, pageLinkLabel: { classPropertyName: "pageLinkLabel", publicName: "pageLinkLabel", isSignal: true, isRequired: false, transformFunction: null }, globalFilter: { classPropertyName: "globalFilter", publicName: "globalFilter", isSignal: true, isRequired: false, transformFunction: null }, globalFilterPlaceholder: { classPropertyName: "globalFilterPlaceholder", publicName: "globalFilterPlaceholder", isSignal: true, isRequired: false, transformFunction: null }, globalFilterAriaLabel: { classPropertyName: "globalFilterAriaLabel", publicName: "globalFilterAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, globalFilterFields: { classPropertyName: "globalFilterFields", publicName: "globalFilterFields", isSignal: true, isRequired: false, transformFunction: null }, selectionMode: { classPropertyName: "selectionMode", publicName: "selectionMode", isSignal: true, isRequired: false, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null }, caption: { classPropertyName: "caption", publicName: "caption", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, scrollHeight: { classPropertyName: "scrollHeight", publicName: "scrollHeight", isSignal: true, isRequired: false, transformFunction: null }, lazy: { classPropertyName: "lazy", publicName: "lazy", isSignal: true, isRequired: false, transformFunction: null }, totalRecords: { classPropertyName: "totalRecords", publicName: "totalRecords", isSignal: true, isRequired: false, transformFunction: null }, expandable: { classPropertyName: "expandable", publicName: "expandable", isSignal: true, isRequired: false, transformFunction: null }, resizableColumns: { classPropertyName: "resizableColumns", publicName: "resizableColumns", isSignal: true, isRequired: false, transformFunction: null }, reorderableColumns: { classPropertyName: "reorderableColumns", publicName: "reorderableColumns", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selection: "selectionChange", sortChange: "sortChange", pageChange: "pageChange", rowSelect: "rowSelect", rowUnselect: "rowUnselect", lazyLoad: "lazyLoad", rowClick: "rowClick", rowExpand: "rowExpand", rowCollapse: "rowCollapse", colReorder: "colReorder" }, host: { classAttribute: "block" }, queries: [{ propertyName: "cellTemplates", predicate: SdsTableCell, isSignal: true }, { propertyName: "rowExpansion", first: true, predicate: SdsTableRowExpansion, descendants: true, isSignal: true }], ngImport: i0, template: `
11995
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsTable, isStandalone: true, selector: "sds-table", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, dataKey: { classPropertyName: "dataKey", publicName: "dataKey", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, striped: { classPropertyName: "striped", publicName: "striped", isSignal: true, isRequired: false, transformFunction: null }, gridlines: { classPropertyName: "gridlines", publicName: "gridlines", isSignal: true, isRequired: false, transformFunction: null }, hoverable: { classPropertyName: "hoverable", publicName: "hoverable", isSignal: true, isRequired: false, transformFunction: null }, sortable: { classPropertyName: "sortable", publicName: "sortable", isSignal: true, isRequired: false, transformFunction: null }, paginator: { classPropertyName: "paginator", publicName: "paginator", isSignal: true, isRequired: false, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: false, transformFunction: null }, rowsPerPageOptions: { classPropertyName: "rowsPerPageOptions", publicName: "rowsPerPageOptions", isSignal: true, isRequired: false, transformFunction: null }, showCurrentPageReport: { classPropertyName: "showCurrentPageReport", publicName: "showCurrentPageReport", isSignal: true, isRequired: false, transformFunction: null }, currentPageReportTemplate: { classPropertyName: "currentPageReportTemplate", publicName: "currentPageReportTemplate", isSignal: true, isRequired: false, transformFunction: null }, rowsPerPageLabel: { classPropertyName: "rowsPerPageLabel", publicName: "rowsPerPageLabel", isSignal: true, isRequired: false, transformFunction: null }, paginationLabel: { classPropertyName: "paginationLabel", publicName: "paginationLabel", isSignal: true, isRequired: false, transformFunction: null }, firstPageLabel: { classPropertyName: "firstPageLabel", publicName: "firstPageLabel", isSignal: true, isRequired: false, transformFunction: null }, previousPageLabel: { classPropertyName: "previousPageLabel", publicName: "previousPageLabel", isSignal: true, isRequired: false, transformFunction: null }, nextPageLabel: { classPropertyName: "nextPageLabel", publicName: "nextPageLabel", isSignal: true, isRequired: false, transformFunction: null }, lastPageLabel: { classPropertyName: "lastPageLabel", publicName: "lastPageLabel", isSignal: true, isRequired: false, transformFunction: null }, pageLinkLabel: { classPropertyName: "pageLinkLabel", publicName: "pageLinkLabel", isSignal: true, isRequired: false, transformFunction: null }, globalFilter: { classPropertyName: "globalFilter", publicName: "globalFilter", isSignal: true, isRequired: false, transformFunction: null }, globalFilterPlaceholder: { classPropertyName: "globalFilterPlaceholder", publicName: "globalFilterPlaceholder", isSignal: true, isRequired: false, transformFunction: null }, globalFilterAriaLabel: { classPropertyName: "globalFilterAriaLabel", publicName: "globalFilterAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, globalFilterFields: { classPropertyName: "globalFilterFields", publicName: "globalFilterFields", isSignal: true, isRequired: false, transformFunction: null }, selectionMode: { classPropertyName: "selectionMode", publicName: "selectionMode", isSignal: true, isRequired: false, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null }, caption: { classPropertyName: "caption", publicName: "caption", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, scrollHeight: { classPropertyName: "scrollHeight", publicName: "scrollHeight", isSignal: true, isRequired: false, transformFunction: null }, lazy: { classPropertyName: "lazy", publicName: "lazy", isSignal: true, isRequired: false, transformFunction: null }, totalRecords: { classPropertyName: "totalRecords", publicName: "totalRecords", isSignal: true, isRequired: false, transformFunction: null }, expandable: { classPropertyName: "expandable", publicName: "expandable", isSignal: true, isRequired: false, transformFunction: null }, resizableColumns: { classPropertyName: "resizableColumns", publicName: "resizableColumns", isSignal: true, isRequired: false, transformFunction: null }, reorderableColumns: { classPropertyName: "reorderableColumns", publicName: "reorderableColumns", isSignal: true, isRequired: false, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null }, editOn: { classPropertyName: "editOn", publicName: "editOn", isSignal: true, isRequired: false, transformFunction: null }, cellErrors: { classPropertyName: "cellErrors", publicName: "cellErrors", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, dirtyLabel: { classPropertyName: "dirtyLabel", publicName: "dirtyLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selection: "selectionChange", sortChange: "sortChange", pageChange: "pageChange", rowSelect: "rowSelect", rowUnselect: "rowUnselect", lazyLoad: "lazyLoad", rowClick: "rowClick", rowExpand: "rowExpand", rowCollapse: "rowCollapse", colReorder: "colReorder", cellCommit: "cellCommit" }, host: { classAttribute: "block" }, queries: [{ propertyName: "cellTemplates", predicate: SdsTableCell, isSignal: true }, { propertyName: "rowExpansion", first: true, predicate: SdsTableRowExpansion, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "numberEditor", first: true, predicate: SdsInputNumber, descendants: true, isSignal: true }], ngImport: i0, template: `
8370
11996
  <div class="rounded-card border border-border bg-surface overflow-hidden">
8371
11997
  <!-- Toolbar: caption + global filter -->
8372
11998
  @if (caption() || globalFilter()) {
@@ -8411,6 +12037,8 @@ class SdsTable {
8411
12037
  >
8412
12038
  <table
8413
12039
  class="w-full border-collapse text-left"
12040
+ [attr.role]="editable() ? 'grid' : null"
12041
+ (keydown)="onGridKeydown($event)"
8414
12042
  [attr.aria-busy]="loading() ? 'true' : null"
8415
12043
  [attr.aria-labelledby]="caption() ? tableId + '-caption' : null"
8416
12044
  [attr.aria-label]="caption() ? null : ariaLabel()"
@@ -8514,7 +12142,7 @@ class SdsTable {
8514
12142
  @for (row of displayRows(); track trackRow($index, row); let i = $index) {
8515
12143
  <tr
8516
12144
  [class]="rowClass(row, i)"
8517
- [attr.tabindex]="selectionMode() === 'single' && !loading() ? 0 : null"
12145
+ [attr.tabindex]="selectionMode() === 'single' && !loading() && !editable() ? 0 : null"
8518
12146
  [attr.aria-selected]="selectionMode() === 'single' ? (isRowSelected(row) ? 'true' : 'false') : null"
8519
12147
  (click)="onRowClick(row)"
8520
12148
  (keydown.enter)="onRowKeydown($event, row)"
@@ -8580,20 +12208,128 @@ class SdsTable {
8580
12208
  </td>
8581
12209
  }
8582
12210
 
8583
- @for (col of effectiveColumns(); track col.field) {
8584
- <td
8585
- [class]="cellClass(col)"
8586
- [style.width]="columnStyleWidth(col)"
8587
- >
8588
- @if (templateFor(col.field); as tpl) {
8589
- <ng-container
8590
- [ngTemplateOutlet]="tpl"
8591
- [ngTemplateOutletContext]="cellContext(row, col, i)"
8592
- />
8593
- } @else {
8594
- {{ display(row, col.field) }}
8595
- }
8596
- </td>
12211
+ @for (col of effectiveColumns(); track col.field; let ci = $index) {
12212
+ @if (!editable()) {
12213
+ <td
12214
+ [class]="cellClass(col)"
12215
+ [style.width]="columnStyleWidth(col)"
12216
+ >
12217
+ @if (templateFor(col.field); as tpl) {
12218
+ <ng-container
12219
+ [ngTemplateOutlet]="tpl"
12220
+ [ngTemplateOutletContext]="cellContext(row, col, i)"
12221
+ />
12222
+ } @else {
12223
+ {{ displayCell(row, col) }}
12224
+ }
12225
+ </td>
12226
+ } @else {
12227
+ <!-- Editable grid cell: one roving tab stop, arrow-key navigation, in-place editor. -->
12228
+ <td
12229
+ role="gridcell"
12230
+ [class]="gridCellClass(row, col, i, ci)"
12231
+ [style.width]="columnStyleWidth(col)"
12232
+ [attr.data-cell]="i + ':' + ci"
12233
+ [attr.tabindex]="isTabStop(i, ci) ? 0 : -1"
12234
+ [attr.aria-readonly]="isCellEditable(row, col) ? null : 'true'"
12235
+ [attr.aria-invalid]="cellErrorFor(row, col.field) ? 'true' : null"
12236
+ [attr.aria-describedby]="cellErrorFor(row, col.field) ? cellErrorId(i, ci) : null"
12237
+ (click)="onCellClick($event, i, ci)"
12238
+ (dblclick)="onCellDblClick($event, i, ci)"
12239
+ (focus)="onCellFocus(i, ci)"
12240
+ >
12241
+ @if (isEditing(i, ci)) {
12242
+ @switch (col.editor) {
12243
+ @case ('select') {
12244
+ <select
12245
+ data-sds-cell-editor
12246
+ class="h-9 w-full rounded-control border border-brand-500 bg-surface px-2 text-sm text-ink outline-none ring-2 ring-brand-500/25"
12247
+ [attr.aria-label]="col.header"
12248
+ (change)="onSelectEditorChange($event, col)"
12249
+ (blur)="onEditorBlur()"
12250
+ >
12251
+ @if (draft() === null || draft() === undefined) {
12252
+ <option value="" disabled selected>{{ col.placeholder ?? '' }}</option>
12253
+ }
12254
+ @for (opt of col.options ?? []; track $index; let oi = $index) {
12255
+ <option [value]="oi" [selected]="opt.value === draft()">{{ opt.label }}</option>
12256
+ }
12257
+ </select>
12258
+ }
12259
+ @case ('number') {
12260
+ <sds-input-number
12261
+ data-sds-cell-editor
12262
+ size="sm"
12263
+ mode="decimal"
12264
+ [locale]="col.locale ?? locale()"
12265
+ [min]="col.min ?? null"
12266
+ [max]="col.max ?? null"
12267
+ [step]="col.step ?? 1"
12268
+ [minFractionDigits]="col.minFractionDigits ?? null"
12269
+ [maxFractionDigits]="col.maxFractionDigits ?? null"
12270
+ [placeholder]="col.placeholder ?? ''"
12271
+ [ariaLabel]="col.header"
12272
+ (focusout)="onEditorBlur()"
12273
+ />
12274
+ }
12275
+ @case ('currency') {
12276
+ <sds-input-number
12277
+ data-sds-cell-editor
12278
+ size="sm"
12279
+ mode="currency"
12280
+ [currency]="col.currency ?? 'EUR'"
12281
+ [locale]="col.locale ?? locale()"
12282
+ [min]="col.min ?? null"
12283
+ [max]="col.max ?? null"
12284
+ [step]="col.step ?? 1"
12285
+ [minFractionDigits]="col.minFractionDigits ?? null"
12286
+ [maxFractionDigits]="col.maxFractionDigits ?? null"
12287
+ [placeholder]="col.placeholder ?? ''"
12288
+ [ariaLabel]="col.header"
12289
+ (focusout)="onEditorBlur()"
12290
+ />
12291
+ }
12292
+ @default {
12293
+ <input
12294
+ data-sds-cell-editor
12295
+ type="text"
12296
+ class="h-9 w-full rounded-control border border-brand-500 bg-surface px-2.5 text-sm text-ink outline-none ring-2 ring-brand-500/25 placeholder:text-ink-faint"
12297
+ [value]="draftText()"
12298
+ [placeholder]="col.placeholder ?? ''"
12299
+ [attr.aria-label]="col.header"
12300
+ (input)="onTextEditorInput($event)"
12301
+ (blur)="onEditorBlur()"
12302
+ />
12303
+ }
12304
+ }
12305
+ } @else {
12306
+ @if (templateFor(col.field); as tpl) {
12307
+ <ng-container
12308
+ [ngTemplateOutlet]="tpl"
12309
+ [ngTemplateOutletContext]="cellContext(row, col, i)"
12310
+ />
12311
+ } @else {
12312
+ {{ displayCell(row, col) }}
12313
+ }
12314
+ @if (isDirty(row, col.field)) {
12315
+ <span
12316
+ class="pointer-events-none absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-500"
12317
+ aria-hidden="true"
12318
+ ></span>
12319
+ <span class="sr-only">({{ dirtyLabel() }})</span>
12320
+ }
12321
+ }
12322
+ @if (cellErrorFor(row, col.field); as message) {
12323
+ <span
12324
+ [id]="cellErrorId(i, ci)"
12325
+ class="mt-1 flex items-start gap-1 text-xs font-medium normal-case text-danger"
12326
+ >
12327
+ <sds-icon name="alert-circle" [size]="13" class="mt-px shrink-0" />
12328
+ <span>{{ message }}</span>
12329
+ </span>
12330
+ }
12331
+ </td>
12332
+ }
8597
12333
  }
8598
12334
  </tr>
8599
12335
 
@@ -8660,14 +12396,14 @@ class SdsTable {
8660
12396
  </div>
8661
12397
  }
8662
12398
  </div>
8663
- `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }, { kind: "component", type: SdsPaginator, selector: "sds-paginator", inputs: ["totalRecords", "first", "rows", "rowsPerPageOptions", "pageLinkSize", "showFirstLastIcon", "showPageLinks", "showCurrentPageReport", "currentPageReportTemplate", "showJumpToPageInput", "rowsPerPageLabel", "jumpToPageLabel", "paginationLabel", "firstPageLabel", "previousPageLabel", "nextPageLabel", "lastPageLabel", "pageLinkLabel", "jumpToPageAriaLabel", "size", "disabled"], outputs: ["firstChange", "rowsChange", "pageChange"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12399
+ `, isInline: true, dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }, { kind: "component", type: SdsPaginator, selector: "sds-paginator", inputs: ["totalRecords", "first", "rows", "rowsPerPageOptions", "pageLinkSize", "showFirstLastIcon", "showPageLinks", "showCurrentPageReport", "currentPageReportTemplate", "showJumpToPageInput", "rowsPerPageLabel", "jumpToPageLabel", "paginationLabel", "firstPageLabel", "previousPageLabel", "nextPageLabel", "lastPageLabel", "pageLinkLabel", "jumpToPageAriaLabel", "size", "disabled"], outputs: ["firstChange", "rowsChange", "pageChange"] }, { kind: "component", type: SdsInputNumber, selector: "sds-input-number", inputs: ["label", "ariaLabel", "placeholder", "hint", "error", "size", "appearance", "disabled", "readonly", "mode", "locale", "currency", "currencyDisplay", "useGrouping", "minFractionDigits", "maxFractionDigits", "prefix", "suffix", "min", "max", "step", "showButtons", "buttonLayout", "incrementIcon", "decrementIcon", "allowEmpty", "clearable", "clearLabel", "incrementLabel", "decrementLabel"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
8664
12400
  }
8665
12401
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsTable, decorators: [{
8666
12402
  type: Component,
8667
12403
  args: [{
8668
12404
  selector: 'sds-table',
8669
12405
  changeDetection: ChangeDetectionStrategy.OnPush,
8670
- imports: [SdsIcon, SdsPaginator, NgTemplateOutlet],
12406
+ imports: [SdsIcon, SdsPaginator, SdsInputNumber, NgTemplateOutlet],
8671
12407
  host: { class: 'block' },
8672
12408
  template: `
8673
12409
  <div class="rounded-card border border-border bg-surface overflow-hidden">
@@ -8714,6 +12450,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
8714
12450
  >
8715
12451
  <table
8716
12452
  class="w-full border-collapse text-left"
12453
+ [attr.role]="editable() ? 'grid' : null"
12454
+ (keydown)="onGridKeydown($event)"
8717
12455
  [attr.aria-busy]="loading() ? 'true' : null"
8718
12456
  [attr.aria-labelledby]="caption() ? tableId + '-caption' : null"
8719
12457
  [attr.aria-label]="caption() ? null : ariaLabel()"
@@ -8817,7 +12555,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
8817
12555
  @for (row of displayRows(); track trackRow($index, row); let i = $index) {
8818
12556
  <tr
8819
12557
  [class]="rowClass(row, i)"
8820
- [attr.tabindex]="selectionMode() === 'single' && !loading() ? 0 : null"
12558
+ [attr.tabindex]="selectionMode() === 'single' && !loading() && !editable() ? 0 : null"
8821
12559
  [attr.aria-selected]="selectionMode() === 'single' ? (isRowSelected(row) ? 'true' : 'false') : null"
8822
12560
  (click)="onRowClick(row)"
8823
12561
  (keydown.enter)="onRowKeydown($event, row)"
@@ -8883,20 +12621,128 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
8883
12621
  </td>
8884
12622
  }
8885
12623
 
8886
- @for (col of effectiveColumns(); track col.field) {
8887
- <td
8888
- [class]="cellClass(col)"
8889
- [style.width]="columnStyleWidth(col)"
8890
- >
8891
- @if (templateFor(col.field); as tpl) {
8892
- <ng-container
8893
- [ngTemplateOutlet]="tpl"
8894
- [ngTemplateOutletContext]="cellContext(row, col, i)"
8895
- />
8896
- } @else {
8897
- {{ display(row, col.field) }}
8898
- }
8899
- </td>
12624
+ @for (col of effectiveColumns(); track col.field; let ci = $index) {
12625
+ @if (!editable()) {
12626
+ <td
12627
+ [class]="cellClass(col)"
12628
+ [style.width]="columnStyleWidth(col)"
12629
+ >
12630
+ @if (templateFor(col.field); as tpl) {
12631
+ <ng-container
12632
+ [ngTemplateOutlet]="tpl"
12633
+ [ngTemplateOutletContext]="cellContext(row, col, i)"
12634
+ />
12635
+ } @else {
12636
+ {{ displayCell(row, col) }}
12637
+ }
12638
+ </td>
12639
+ } @else {
12640
+ <!-- Editable grid cell: one roving tab stop, arrow-key navigation, in-place editor. -->
12641
+ <td
12642
+ role="gridcell"
12643
+ [class]="gridCellClass(row, col, i, ci)"
12644
+ [style.width]="columnStyleWidth(col)"
12645
+ [attr.data-cell]="i + ':' + ci"
12646
+ [attr.tabindex]="isTabStop(i, ci) ? 0 : -1"
12647
+ [attr.aria-readonly]="isCellEditable(row, col) ? null : 'true'"
12648
+ [attr.aria-invalid]="cellErrorFor(row, col.field) ? 'true' : null"
12649
+ [attr.aria-describedby]="cellErrorFor(row, col.field) ? cellErrorId(i, ci) : null"
12650
+ (click)="onCellClick($event, i, ci)"
12651
+ (dblclick)="onCellDblClick($event, i, ci)"
12652
+ (focus)="onCellFocus(i, ci)"
12653
+ >
12654
+ @if (isEditing(i, ci)) {
12655
+ @switch (col.editor) {
12656
+ @case ('select') {
12657
+ <select
12658
+ data-sds-cell-editor
12659
+ class="h-9 w-full rounded-control border border-brand-500 bg-surface px-2 text-sm text-ink outline-none ring-2 ring-brand-500/25"
12660
+ [attr.aria-label]="col.header"
12661
+ (change)="onSelectEditorChange($event, col)"
12662
+ (blur)="onEditorBlur()"
12663
+ >
12664
+ @if (draft() === null || draft() === undefined) {
12665
+ <option value="" disabled selected>{{ col.placeholder ?? '' }}</option>
12666
+ }
12667
+ @for (opt of col.options ?? []; track $index; let oi = $index) {
12668
+ <option [value]="oi" [selected]="opt.value === draft()">{{ opt.label }}</option>
12669
+ }
12670
+ </select>
12671
+ }
12672
+ @case ('number') {
12673
+ <sds-input-number
12674
+ data-sds-cell-editor
12675
+ size="sm"
12676
+ mode="decimal"
12677
+ [locale]="col.locale ?? locale()"
12678
+ [min]="col.min ?? null"
12679
+ [max]="col.max ?? null"
12680
+ [step]="col.step ?? 1"
12681
+ [minFractionDigits]="col.minFractionDigits ?? null"
12682
+ [maxFractionDigits]="col.maxFractionDigits ?? null"
12683
+ [placeholder]="col.placeholder ?? ''"
12684
+ [ariaLabel]="col.header"
12685
+ (focusout)="onEditorBlur()"
12686
+ />
12687
+ }
12688
+ @case ('currency') {
12689
+ <sds-input-number
12690
+ data-sds-cell-editor
12691
+ size="sm"
12692
+ mode="currency"
12693
+ [currency]="col.currency ?? 'EUR'"
12694
+ [locale]="col.locale ?? locale()"
12695
+ [min]="col.min ?? null"
12696
+ [max]="col.max ?? null"
12697
+ [step]="col.step ?? 1"
12698
+ [minFractionDigits]="col.minFractionDigits ?? null"
12699
+ [maxFractionDigits]="col.maxFractionDigits ?? null"
12700
+ [placeholder]="col.placeholder ?? ''"
12701
+ [ariaLabel]="col.header"
12702
+ (focusout)="onEditorBlur()"
12703
+ />
12704
+ }
12705
+ @default {
12706
+ <input
12707
+ data-sds-cell-editor
12708
+ type="text"
12709
+ class="h-9 w-full rounded-control border border-brand-500 bg-surface px-2.5 text-sm text-ink outline-none ring-2 ring-brand-500/25 placeholder:text-ink-faint"
12710
+ [value]="draftText()"
12711
+ [placeholder]="col.placeholder ?? ''"
12712
+ [attr.aria-label]="col.header"
12713
+ (input)="onTextEditorInput($event)"
12714
+ (blur)="onEditorBlur()"
12715
+ />
12716
+ }
12717
+ }
12718
+ } @else {
12719
+ @if (templateFor(col.field); as tpl) {
12720
+ <ng-container
12721
+ [ngTemplateOutlet]="tpl"
12722
+ [ngTemplateOutletContext]="cellContext(row, col, i)"
12723
+ />
12724
+ } @else {
12725
+ {{ displayCell(row, col) }}
12726
+ }
12727
+ @if (isDirty(row, col.field)) {
12728
+ <span
12729
+ class="pointer-events-none absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-500"
12730
+ aria-hidden="true"
12731
+ ></span>
12732
+ <span class="sr-only">({{ dirtyLabel() }})</span>
12733
+ }
12734
+ }
12735
+ @if (cellErrorFor(row, col.field); as message) {
12736
+ <span
12737
+ [id]="cellErrorId(i, ci)"
12738
+ class="mt-1 flex items-start gap-1 text-xs font-medium normal-case text-danger"
12739
+ >
12740
+ <sds-icon name="alert-circle" [size]="13" class="mt-px shrink-0" />
12741
+ <span>{{ message }}</span>
12742
+ </span>
12743
+ }
12744
+ </td>
12745
+ }
8900
12746
  }
8901
12747
  </tr>
8902
12748
 
@@ -8965,7 +12811,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
8965
12811
  </div>
8966
12812
  `,
8967
12813
  }]
8968
- }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], dataKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataKey", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], striped: [{ type: i0.Input, args: [{ isSignal: true, alias: "striped", required: false }] }], gridlines: [{ type: i0.Input, args: [{ isSignal: true, alias: "gridlines", required: false }] }], hoverable: [{ type: i0.Input, args: [{ isSignal: true, alias: "hoverable", required: false }] }], sortable: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortable", required: false }] }], paginator: [{ type: i0.Input, args: [{ isSignal: true, alias: "paginator", required: false }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: false }] }], rowsPerPageOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowsPerPageOptions", required: false }] }], showCurrentPageReport: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCurrentPageReport", required: false }] }], currentPageReportTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentPageReportTemplate", required: false }] }], rowsPerPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowsPerPageLabel", required: false }] }], paginationLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "paginationLabel", required: false }] }], firstPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "firstPageLabel", required: false }] }], previousPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "previousPageLabel", required: false }] }], nextPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "nextPageLabel", required: false }] }], lastPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "lastPageLabel", required: false }] }], pageLinkLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageLinkLabel", required: false }] }], globalFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilter", required: false }] }], globalFilterPlaceholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilterPlaceholder", required: false }] }], globalFilterAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilterAriaLabel", required: false }] }], globalFilterFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilterFields", required: false }] }], selectionMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectionMode", required: false }] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }, { type: i0.Output, args: ["selectionChange"] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], emptyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyMessage", required: false }] }], caption: [{ type: i0.Input, args: [{ isSignal: true, alias: "caption", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], scrollHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollHeight", required: false }] }], lazy: [{ type: i0.Input, args: [{ isSignal: true, alias: "lazy", required: false }] }], totalRecords: [{ type: i0.Input, args: [{ isSignal: true, alias: "totalRecords", required: false }] }], expandable: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandable", required: false }] }], resizableColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "resizableColumns", required: false }] }], reorderableColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "reorderableColumns", required: false }] }], sortChange: [{ type: i0.Output, args: ["sortChange"] }], pageChange: [{ type: i0.Output, args: ["pageChange"] }], rowSelect: [{ type: i0.Output, args: ["rowSelect"] }], rowUnselect: [{ type: i0.Output, args: ["rowUnselect"] }], lazyLoad: [{ type: i0.Output, args: ["lazyLoad"] }], rowClick: [{ type: i0.Output, args: ["rowClick"] }], rowExpand: [{ type: i0.Output, args: ["rowExpand"] }], rowCollapse: [{ type: i0.Output, args: ["rowCollapse"] }], colReorder: [{ type: i0.Output, args: ["colReorder"] }], cellTemplates: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => SdsTableCell), { isSignal: true }] }], rowExpansion: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SdsTableRowExpansion), { isSignal: true }] }] } });
12814
+ }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], dataKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataKey", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], striped: [{ type: i0.Input, args: [{ isSignal: true, alias: "striped", required: false }] }], gridlines: [{ type: i0.Input, args: [{ isSignal: true, alias: "gridlines", required: false }] }], hoverable: [{ type: i0.Input, args: [{ isSignal: true, alias: "hoverable", required: false }] }], sortable: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortable", required: false }] }], paginator: [{ type: i0.Input, args: [{ isSignal: true, alias: "paginator", required: false }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: false }] }], rowsPerPageOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowsPerPageOptions", required: false }] }], showCurrentPageReport: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCurrentPageReport", required: false }] }], currentPageReportTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentPageReportTemplate", required: false }] }], rowsPerPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowsPerPageLabel", required: false }] }], paginationLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "paginationLabel", required: false }] }], firstPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "firstPageLabel", required: false }] }], previousPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "previousPageLabel", required: false }] }], nextPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "nextPageLabel", required: false }] }], lastPageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "lastPageLabel", required: false }] }], pageLinkLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageLinkLabel", required: false }] }], globalFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilter", required: false }] }], globalFilterPlaceholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilterPlaceholder", required: false }] }], globalFilterAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilterAriaLabel", required: false }] }], globalFilterFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalFilterFields", required: false }] }], selectionMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectionMode", required: false }] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }, { type: i0.Output, args: ["selectionChange"] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], emptyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyMessage", required: false }] }], caption: [{ type: i0.Input, args: [{ isSignal: true, alias: "caption", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], scrollHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollHeight", required: false }] }], lazy: [{ type: i0.Input, args: [{ isSignal: true, alias: "lazy", required: false }] }], totalRecords: [{ type: i0.Input, args: [{ isSignal: true, alias: "totalRecords", required: false }] }], expandable: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandable", required: false }] }], resizableColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "resizableColumns", required: false }] }], reorderableColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "reorderableColumns", required: false }] }], editable: [{ type: i0.Input, args: [{ isSignal: true, alias: "editable", required: false }] }], editOn: [{ type: i0.Input, args: [{ isSignal: true, alias: "editOn", required: false }] }], cellErrors: [{ type: i0.Input, args: [{ isSignal: true, alias: "cellErrors", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], dirtyLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "dirtyLabel", required: false }] }], sortChange: [{ type: i0.Output, args: ["sortChange"] }], pageChange: [{ type: i0.Output, args: ["pageChange"] }], rowSelect: [{ type: i0.Output, args: ["rowSelect"] }], rowUnselect: [{ type: i0.Output, args: ["rowUnselect"] }], lazyLoad: [{ type: i0.Output, args: ["lazyLoad"] }], rowClick: [{ type: i0.Output, args: ["rowClick"] }], rowExpand: [{ type: i0.Output, args: ["rowExpand"] }], rowCollapse: [{ type: i0.Output, args: ["rowCollapse"] }], colReorder: [{ type: i0.Output, args: ["colReorder"] }], cellCommit: [{ type: i0.Output, args: ["cellCommit"] }], cellTemplates: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => SdsTableCell), { isSignal: true }] }], rowExpansion: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SdsTableRowExpansion), { isSignal: true }] }], numberEditor: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SdsInputNumber), { isSignal: true }] }] } });
8969
12815
 
8970
12816
  /**
8971
12817
  * A tiny transient-notification bus. Application code calls {@link success} / {@link error} / {@link info}
@@ -10192,6 +14038,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10192
14038
  *
10193
14039
  * @example
10194
14040
  * <sds-nav-item icon="home" label="Accueil" link="/" [exact]="true" />
14041
+ * <sds-nav-item icon="book-open" label="Menu" link="/menu" [badge]="3" badgeLabel="{count} issues" badgeTone="danger" />
10195
14042
  */
10196
14043
  class SdsNavItem {
10197
14044
  store = inject(SidebarStore);
@@ -10202,8 +14049,52 @@ class SdsNavItem {
10202
14049
  exact = input(false, ...(ngDevMode ? [{ debugName: "exact", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
10203
14050
  /** Manual active state (combined with routerLinkActive when a link is set). */
10204
14051
  active = input(false, ...(ngDevMode ? [{ debugName: "active", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
14052
+ /**
14053
+ * Optional count shown as a pill after the label (e.g. menu issues to fix). Hidden when null or 0.
14054
+ * On the collapsed rail it sits on the icon's corner.
14055
+ */
14056
+ badge = input(null, ...(ngDevMode ? [{ debugName: "badge" }] : []));
14057
+ /** Spoken text for the badge; `{count}` is replaced. Pass a translated string. */
14058
+ badgeLabel = input('{count} pending', ...(ngDevMode ? [{ debugName: "badgeLabel" }] : []));
14059
+ /** Counts above this read as "max+" (e.g. 99+). */
14060
+ badgeMax = input(99, ...(ngDevMode ? [{ debugName: "badgeMax" }] : []));
14061
+ /** Badge colour. `danger` for things that block, `brand` (default) for a neutral count. */
14062
+ badgeTone = input('brand', ...(ngDevMode ? [{ debugName: "badgeTone" }] : []));
10205
14063
  select = output();
10206
14064
  collapsed = this.store.collapsed;
14065
+ /** The badge's spoken text, or null when there is nothing to show. */
14066
+ badgeText = computed(() => {
14067
+ const n = this.badge();
14068
+ if (n === null || n === undefined || !Number.isFinite(n) || n <= 0)
14069
+ return null;
14070
+ return this.badgeLabel().replace('{count}', String(n));
14071
+ }, ...(ngDevMode ? [{ debugName: "badgeText" }] : []));
14072
+ badgeDisplay = computed(() => {
14073
+ const n = this.badge() ?? 0;
14074
+ return n > this.badgeMax() ? `${this.badgeMax()}+` : String(n);
14075
+ }, ...(ngDevMode ? [{ debugName: "badgeDisplay" }] : []));
14076
+ /** Accessible name on the collapsed rail, where the visible label is hidden. */
14077
+ collapsedName = computed(() => {
14078
+ const text = this.badgeText();
14079
+ return text ? `${this.label()}, ${text}` : this.label();
14080
+ }, ...(ngDevMode ? [{ debugName: "collapsedName" }] : []));
14081
+ badgeClass = computed(() => {
14082
+ const tone = this.badgeTone() === 'danger'
14083
+ ? 'bg-danger text-white'
14084
+ : this.badgeTone() === 'neutral'
14085
+ ? 'bg-muted text-ink-soft border border-border'
14086
+ : this.store.appearance() === 'surface'
14087
+ ? 'bg-brand-600 text-white dark:bg-ink dark:text-surface'
14088
+ : 'bg-white text-brand-800';
14089
+ const base = 'ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 ' +
14090
+ 'text-[11px] font-bold tabular-nums leading-none';
14091
+ // Collapsed desktop rail: a small pill pinned to the top-right of the centred icon.
14092
+ const collapsed = this.collapsed()
14093
+ ? ' lg:absolute lg:right-1.5 lg:top-0.5 lg:ml-0 lg:h-4 lg:min-w-4 lg:px-1 lg:text-[10px]' +
14094
+ (this.store.appearance() === 'surface' ? ' lg:ring-2 lg:ring-surface' : '')
14095
+ : '';
14096
+ return `${base} ${tone}${collapsed}`;
14097
+ }, ...(ngDevMode ? [{ debugName: "badgeClass" }] : []));
10207
14098
  itemClass(isActive) {
10208
14099
  // A `surface` rail is light: an active item is a brand-tinted pill with a left accent bar, brand
10209
14100
  // text and a soft shadow (matching the certicheck reference); inactive is muted ink that lifts on
@@ -10231,7 +14122,7 @@ class SdsNavItem {
10231
14122
  accentBarClass = computed(() => 'pointer-events-none absolute left-1 top-1/2 h-5 w-1 -translate-y-1/2 rounded-full ' +
10232
14123
  (this.store.appearance() === 'surface' ? 'bg-brand-500' : 'bg-white'), ...(ngDevMode ? [{ debugName: "accentBarClass" }] : []));
10233
14124
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsNavItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
10234
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsNavItem, isStandalone: true, selector: "sds-nav-item", inputs: { icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, link: { classPropertyName: "link", publicName: "link", isSignal: true, isRequired: false, transformFunction: null }, exact: { classPropertyName: "exact", publicName: "exact", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { select: "select" }, host: { classAttribute: "block" }, ngImport: i0, template: `
14125
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsNavItem, isStandalone: true, selector: "sds-nav-item", inputs: { icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, link: { classPropertyName: "link", publicName: "link", isSignal: true, isRequired: false, transformFunction: null }, exact: { classPropertyName: "exact", publicName: "exact", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, badge: { classPropertyName: "badge", publicName: "badge", isSignal: true, isRequired: false, transformFunction: null }, badgeLabel: { classPropertyName: "badgeLabel", publicName: "badgeLabel", isSignal: true, isRequired: false, transformFunction: null }, badgeMax: { classPropertyName: "badgeMax", publicName: "badgeMax", isSignal: true, isRequired: false, transformFunction: null }, badgeTone: { classPropertyName: "badgeTone", publicName: "badgeTone", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { select: "select" }, host: { classAttribute: "block" }, ngImport: i0, template: `
10235
14126
  @if (link() !== null) {
10236
14127
  <a
10237
14128
  [routerLink]="link()"
@@ -10239,8 +14130,8 @@ class SdsNavItem {
10239
14130
  #rla="routerLinkActive"
10240
14131
  [routerLinkActiveOptions]="{ exact: exact() }"
10241
14132
  [class]="itemClass(rla.isActive || active())"
10242
- [attr.title]="collapsed() ? label() : null"
10243
- [attr.aria-label]="collapsed() ? label() : null"
14133
+ [attr.title]="collapsed() ? collapsedName() : null"
14134
+ [attr.aria-label]="collapsed() ? collapsedName() : null"
10244
14135
  [attr.aria-current]="rla.isActive || active() ? 'page' : null"
10245
14136
  (click)="store.closeMobile()"
10246
14137
  >
@@ -10253,8 +14144,8 @@ class SdsNavItem {
10253
14144
  <button
10254
14145
  type="button"
10255
14146
  [class]="itemClass(active())"
10256
- [attr.title]="collapsed() ? label() : null"
10257
- [attr.aria-label]="collapsed() ? label() : null"
14147
+ [attr.title]="collapsed() ? collapsedName() : null"
14148
+ [attr.aria-label]="collapsed() ? collapsedName() : null"
10258
14149
  [attr.aria-current]="active() ? 'page' : null"
10259
14150
  (click)="select.emit(); store.closeMobile()"
10260
14151
  >
@@ -10271,6 +14162,12 @@ class SdsNavItem {
10271
14162
  }
10272
14163
  <!-- Label is hidden only on the collapsed desktop rail; always shown in the mobile drawer. -->
10273
14164
  <span class="truncate" [class]="collapsed() ? 'lg:hidden' : ''">{{ label() }}</span>
14165
+ @if (badgeText(); as text) {
14166
+ <!-- The number is drawn; the words are what is spoken ("Menu, 3 pending"). On the collapsed
14167
+ desktop rail it becomes a small corner pill on the icon. -->
14168
+ <span [class]="badgeClass()" aria-hidden="true">{{ badgeDisplay() }}</span>
14169
+ <span class="sr-only">, {{ text }}</span>
14170
+ }
10274
14171
  </ng-template>
10275
14172
  `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10276
14173
  }
@@ -10289,8 +14186,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10289
14186
  #rla="routerLinkActive"
10290
14187
  [routerLinkActiveOptions]="{ exact: exact() }"
10291
14188
  [class]="itemClass(rla.isActive || active())"
10292
- [attr.title]="collapsed() ? label() : null"
10293
- [attr.aria-label]="collapsed() ? label() : null"
14189
+ [attr.title]="collapsed() ? collapsedName() : null"
14190
+ [attr.aria-label]="collapsed() ? collapsedName() : null"
10294
14191
  [attr.aria-current]="rla.isActive || active() ? 'page' : null"
10295
14192
  (click)="store.closeMobile()"
10296
14193
  >
@@ -10303,8 +14200,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10303
14200
  <button
10304
14201
  type="button"
10305
14202
  [class]="itemClass(active())"
10306
- [attr.title]="collapsed() ? label() : null"
10307
- [attr.aria-label]="collapsed() ? label() : null"
14203
+ [attr.title]="collapsed() ? collapsedName() : null"
14204
+ [attr.aria-label]="collapsed() ? collapsedName() : null"
10308
14205
  [attr.aria-current]="active() ? 'page' : null"
10309
14206
  (click)="select.emit(); store.closeMobile()"
10310
14207
  >
@@ -10321,10 +14218,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10321
14218
  }
10322
14219
  <!-- Label is hidden only on the collapsed desktop rail; always shown in the mobile drawer. -->
10323
14220
  <span class="truncate" [class]="collapsed() ? 'lg:hidden' : ''">{{ label() }}</span>
14221
+ @if (badgeText(); as text) {
14222
+ <!-- The number is drawn; the words are what is spoken ("Menu, 3 pending"). On the collapsed
14223
+ desktop rail it becomes a small corner pill on the icon. -->
14224
+ <span [class]="badgeClass()" aria-hidden="true">{{ badgeDisplay() }}</span>
14225
+ <span class="sr-only">, {{ text }}</span>
14226
+ }
10324
14227
  </ng-template>
10325
14228
  `,
10326
14229
  }]
10327
- }], propDecorators: { icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], link: [{ type: i0.Input, args: [{ isSignal: true, alias: "link", required: false }] }], exact: [{ type: i0.Input, args: [{ isSignal: true, alias: "exact", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], select: [{ type: i0.Output, args: ["select"] }] } });
14230
+ }], propDecorators: { icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], link: [{ type: i0.Input, args: [{ isSignal: true, alias: "link", required: false }] }], exact: [{ type: i0.Input, args: [{ isSignal: true, alias: "exact", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], badge: [{ type: i0.Input, args: [{ isSignal: true, alias: "badge", required: false }] }], badgeLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "badgeLabel", required: false }] }], badgeMax: [{ type: i0.Input, args: [{ isSignal: true, alias: "badgeMax", required: false }] }], badgeTone: [{ type: i0.Input, args: [{ isSignal: true, alias: "badgeTone", required: false }] }], select: [{ type: i0.Output, args: ["select"] }] } });
10328
14231
 
10329
14232
  /**
10330
14233
  * The signed-in user's trigger and dropdown for the top bar: an avatar (with the name and email beside
@@ -11547,5 +15450,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
11547
15450
  * Generated bundle index. Do not edit.
11548
15451
  */
11549
15452
 
11550
- export { SDS_ICONS, SDS_PHONE_COUNTRIES, SdsAccordion, SdsAccordionContent, SdsAccordionHeader, SdsAccordionPanel, SdsAccordionStore, SdsAvatar, SdsBadge, SdsButton, SdsCalendar, SdsCard, SdsCheckbox, SdsDatePicker, SdsIcon, SdsInput, SdsInputNumber, SdsLanguageSwitcher, SdsLocalizedInput, SdsMenuItem, SdsMultiSelect, SdsNavGroup, SdsNavItem, SdsOtpInput, SdsPaginator, SdsPhoneInput, SdsRadio, SdsSearchCommand, SdsSelect, SdsSelectEmpty, SdsSelectFooter, SdsSelectOption, SdsShell, SdsSidebar, SdsTab, SdsTabList, SdsTabPanel, SdsTabPanels, SdsTable, SdsTableCell, SdsTableRowExpansion, SdsTabs, SdsTabsStore, SdsTextarea, SdsThemeService, SdsThemeToggle, SdsToastHost, SdsToastService, SdsToggleSwitch, SdsTopbar, SdsUserMenu, SidebarStore, addDays, addMonths, addMonthsKeepDay, formatDdMmYyyy, formatHhMm, isOutOfRange, isSameDay, isoDay, pad2, sdsFormatNational, sdsMatchDialCode, sdsNormalizeSignificant, startOfDay, startOfMonth };
15453
+ export { SDS_ALLERGENS, SDS_ALLERGEN_ICONS, SDS_ALLERGEN_LABELS_EN, SDS_ICONS, SDS_PHONE_COUNTRIES, SdsAccordion, SdsAccordionContent, SdsAccordionHeader, SdsAccordionPanel, SdsAccordionStore, SdsAllergenIcon, SdsAvatar, SdsBadge, SdsBulkBar, SdsButton, SdsCalendar, SdsCard, SdsCheckbox, SdsChecklistRow, SdsDatePicker, SdsFileDrop, SdsIcon, SdsImageCropper, SdsInput, SdsInputNumber, SdsLanguageSwitcher, SdsLocalizedInput, SdsMenu, SdsMenuItem, SdsMenuTrigger, SdsMultiSelect, SdsNavGroup, SdsNavItem, SdsOtpInput, SdsPaginator, SdsPhoneInput, SdsRadio, SdsScore, SdsSearchCommand, SdsSegmented, SdsSelect, SdsSelectEmpty, SdsSelectFooter, SdsSelectOption, SdsShell, SdsSidebar, SdsSortableHandle, SdsSortableItem, SdsSortableList, SdsStatusTile, SdsTab, SdsTabList, SdsTabPanel, SdsTabPanels, SdsTable, SdsTableCell, SdsTableRowExpansion, SdsTabs, SdsTabsStore, SdsTextarea, SdsThemeService, SdsThemeToggle, SdsToastHost, SdsToastService, SdsToggleSwitch, SdsTopbar, SdsTriStateChip, SdsUserMenu, SidebarStore, addDays, addMonths, addMonthsKeepDay, formatDdMmYyyy, formatHhMm, isOutOfRange, isSameDay, isoDay, pad2, sdsFileMatchesAccept, sdsFormatBytes, sdsFormatNational, sdsMatchDialCode, sdsNormalizeSignificant, startOfDay, startOfMonth };
11551
15454
  //# sourceMappingURL=besaitech-ng-design-system.mjs.map