spiderly 19.12.0 → 19.12.2

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.
@@ -4101,8 +4101,20 @@ class SpiderlyFilterBarComponent {
4101
4101
  * about columns, so whoever owns the sort resolves the names.
4102
4102
  */
4103
4103
  this.sort = input([]);
4104
+ /**
4105
+ * What the picker offers, labelled by the owner — every sortable column, HIDDEN ones
4106
+ * included (their ordering is otherwise unreachable, which is the trap the picker closes).
4107
+ * Empty keeps the chip read-only and the picker off.
4108
+ */
4109
+ this.sortOptions = input([]);
4110
+ /** Whether the grid already sits on its default ordering — gates the reset row, nothing else. */
4111
+ this.sortIsDefault = input(true);
4104
4112
  /** Asked for, never done here — see the button's comment. */
4105
4113
  this.clearAll = output();
4114
+ /** A single-sort ask. Multi-sort stays a header gesture (ctrl-click); the picker replaces. */
4115
+ this.sortPick = output();
4116
+ /** Back to the owner's default ordering — the owner knows what that resolves to, not the bar. */
4117
+ this.sortReset = output();
4106
4118
  this.transloco = inject(TranslocoService);
4107
4119
  /** For the chip's date wording — the same locale mechanism the table's cells format with. */
4108
4120
  this.locale = inject(LOCALE_ID);
@@ -4113,6 +4125,7 @@ class SpiderlyFilterBarComponent {
4113
4125
  */
4114
4126
  this.controlTemplates = input(undefined);
4115
4127
  this.isAddOpen = signal(false);
4128
+ this.isSortOpen = signal(false);
4116
4129
  /** Cleared when the popover closes, so reopening never starts inside someone's old query. */
4117
4130
  this.addSearch = signal('');
4118
4131
  /** The filter whose control is open. One at a time: the bar edits, it is not a form. */
@@ -4143,6 +4156,22 @@ class SpiderlyFilterBarComponent {
4143
4156
  .filter(([id, definition]) => !onBar.has(id) && definition.offered !== false)
4144
4157
  .map(([id, definition]) => ({ id, ...definition }));
4145
4158
  });
4159
+ /**
4160
+ * The menu's rows: each option decorated with its ACTIVE direction's arrow (any key, not
4161
+ * just the primary), or null. A `computed`, not a per-row method call in the binding — the
4162
+ * popover's content is projected, so it change-detects even while closed, and a `find` per
4163
+ * option per pass is exactly what `operatorChoices` documents against.
4164
+ */
4165
+ this.sortRows = computed(() => {
4166
+ const active = new Map(this.sort().map((key) => [key.field, key.descending]));
4167
+ return this.sortOptions().map((option) => {
4168
+ const descending = active.get(option.field);
4169
+ return {
4170
+ ...option,
4171
+ arrow: descending == null ? null : descending ? '↓' : '↑',
4172
+ };
4173
+ });
4174
+ });
4146
4175
  /**
4147
4176
  * The open editor's operator options, already translated — p-select takes labels, not keys.
4148
4177
  *
@@ -4166,6 +4195,16 @@ class SpiderlyFilterBarComponent {
4166
4195
  this.isAddOpen.set(false);
4167
4196
  this.editing.set(this.filters().get(id));
4168
4197
  }
4198
+ /**
4199
+ * Ascending first; picking the already-ascending primary flips it. The direction is decided
4200
+ * HERE so the owner applies exactly what the menu showed — an owner guessing "toggle" from its
4201
+ * own state could disagree with a stale menu after a concurrent header click.
4202
+ */
4203
+ pickSort(option) {
4204
+ const primary = this.sort()[0];
4205
+ const order = primary?.field === option.field && !primary.descending ? -1 : 1;
4206
+ this.sortPick.emit({ field: option.field, order });
4207
+ }
4169
4208
  /** The raw-string controls only. Nothing reaches the bar or the query until `apply`. */
4170
4209
  draft(handle, event) {
4171
4210
  this.draftValue(handle, this.coerce(handle.kind, event.target.value));
@@ -4234,7 +4273,7 @@ class SpiderlyFilterBarComponent {
4234
4273
  : spellOptionValue(options, chip.value);
4235
4274
  }
4236
4275
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyFilterBarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4237
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.13", type: SpiderlyFilterBarComponent, isStandalone: true, selector: "spiderly-filter-bar", inputs: { filters: { classPropertyName: "filters", publicName: "filters", isSignal: true, isRequired: true, transformFunction: null }, totalRecords: { classPropertyName: "totalRecords", publicName: "totalRecords", isSignal: true, isRequired: false, transformFunction: null }, sort: { classPropertyName: "sort", publicName: "sort", isSignal: true, isRequired: false, transformFunction: null }, controlTemplates: { classPropertyName: "controlTemplates", publicName: "controlTemplates", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { clearAll: "clearAll" }, ngImport: i0, template: `
4276
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.13", type: SpiderlyFilterBarComponent, isStandalone: true, selector: "spiderly-filter-bar", inputs: { filters: { classPropertyName: "filters", publicName: "filters", isSignal: true, isRequired: true, transformFunction: null }, totalRecords: { classPropertyName: "totalRecords", publicName: "totalRecords", isSignal: true, isRequired: false, transformFunction: null }, sort: { classPropertyName: "sort", publicName: "sort", isSignal: true, isRequired: false, transformFunction: null }, sortOptions: { classPropertyName: "sortOptions", publicName: "sortOptions", isSignal: true, isRequired: false, transformFunction: null }, sortIsDefault: { classPropertyName: "sortIsDefault", publicName: "sortIsDefault", isSignal: true, isRequired: false, transformFunction: null }, controlTemplates: { classPropertyName: "controlTemplates", publicName: "controlTemplates", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { clearAll: "clearAll", sortPick: "sortPick", sortReset: "sortReset" }, ngImport: i0, template: `
4238
4277
  <ng-container *transloco="let t">
4239
4278
  <div class="filter-bar" data-testid="filter-bar">
4240
4279
  @for (chip of filters().applied(); track chip.id) {
@@ -4277,23 +4316,49 @@ class SpiderlyFilterBarComponent {
4277
4316
  </span>
4278
4317
  }
4279
4318
 
4280
- @if (sort().length) {
4281
- <!-- Read-only, and that is the scope on purpose. Multi-sort is invisible today (sort
4282
- icons scattered over headers, nothing saying which is primary), so saying it in one
4283
- place is the whole gain. An x here would have to mean "back to the DEFAULT sort"
4284
- rather than "no sort", because applyDefaultSortIfUnsorted puts the default straight
4285
- back — an affordance that lies is worse than none. -->
4319
+ @if (sortOptions().length) {
4320
+ <!-- The chip is the PICKER's trigger (2026-09-06, grilled) — Polaris's always-present
4321
+ SortButton, in the slot the read-only chip held. Always rendered, sorted or not:
4322
+ with the trigger only on an active sort, an unsorted grid would have no way to
4323
+ sort by a hidden column, which is the capability the picker exists for. It
4324
+ replaced a read-only chip whose stated rationale ("an x would lie — the default
4325
+ snaps back") inverted the day a viewed table had no default: the operator was
4326
+ left with an inherited sort, no chip x, no clear button, and the one header that
4327
+ could un-sort it hidden. -->
4328
+ <button
4329
+ type="button"
4330
+ class="sort-chip"
4331
+ data-testid="sort-chip"
4332
+ [attr.aria-expanded]="isSortOpen()"
4333
+ (click)="sortMenu.toggle($event); isSortOpen.set(!isSortOpen())"
4334
+ >
4335
+ <ng-container *ngTemplateOutlet="sortChipBody"></ng-container>
4336
+ <i class="pi pi-chevron-down sort-chip-caret" aria-hidden="true"></i>
4337
+ </button>
4338
+ } @else if (sort().length) {
4339
+ <!-- No options handed over — the chip stays what it was: one read-only place naming
4340
+ the ordering and which key is primary. -->
4286
4341
  <span class="sort-chip" data-testid="sort-chip">
4342
+ <ng-container *ngTemplateOutlet="sortChipBody"></ng-container>
4343
+ </span>
4344
+ }
4345
+
4346
+ <!-- One home for the chip's sentence, whichever element renders it — the read-only
4347
+ branch never reaches the @else (it is gated on sort().length). -->
4348
+ <ng-template #sortChipBody>
4349
+ @if (sort().length) {
4287
4350
  <span class="sort-chip-label">{{ t('SortedBy') }}</span>
4288
- @for (key of sort(); track key.label; let last = $last) {
4351
+ @for (key of sort(); track key.field; let last = $last) {
4289
4352
  <span class="sort-chip-key"
4290
4353
  >{{ key.label }} {{ key.descending ? '↓' : '↑' }}{{
4291
4354
  last ? '' : ','
4292
4355
  }}</span
4293
4356
  >
4294
4357
  }
4295
- </span>
4296
- }
4358
+ } @else {
4359
+ <span class="sort-chip-label">{{ t('Sort') }}</span>
4360
+ }
4361
+ </ng-template>
4297
4362
 
4298
4363
  @if (totalRecords() !== null) {
4299
4364
  <!-- What came back, next to the chips that say what was asked. An empty grid under a
@@ -4325,8 +4390,8 @@ class SpiderlyFilterBarComponent {
4325
4390
  @if (filters().applied().length) {
4326
4391
  <!-- The bar owns clearing now: the toolbar's button sat a row away from the chips it
4327
4392
  cleared, which is two affordances for one job. Emits rather than clearing the store
4328
- itself, because whoever owns the query also owns the persisted state and the sort
4329
- that a full clear has to reach. -->
4393
+ itself, because whoever owns the query owns the requery and the persisted snapshot
4394
+ that follow a clear. Filters only — sort is its own channel (the picker above). -->
4330
4395
  <button
4331
4396
  pButton
4332
4397
  type="button"
@@ -4338,6 +4403,46 @@ class SpiderlyFilterBarComponent {
4338
4403
  ></button>
4339
4404
  }
4340
4405
 
4406
+ <p-popover #sortMenu (onHide)="isSortOpen.set(false)">
4407
+ <div class="filter-add-menu" role="menu">
4408
+ @for (option of sortRows(); track option.field) {
4409
+ <!-- Click sorts ascending; clicking the already-ascending option flips it. One
4410
+ gesture, look, one gesture back — the header's cycle without needing the
4411
+ header, so a hidden column's ordering is reachable too. -->
4412
+ <button
4413
+ type="button"
4414
+ role="menuitem"
4415
+ class="filter-add-option sort-menu-option"
4416
+ data-testid="sort-menu-option"
4417
+ (click)="pickSort(option); sortMenu.hide()"
4418
+ >
4419
+ <!-- &ngsp; keeps a real space between label and arrow — same collapse the
4420
+ filter chip documents: without it the copied/read text is "Naziv↓". -->
4421
+ <span>{{ option.label }}</span>&ngsp;
4422
+ @if (option.arrow) {
4423
+ <span class="sort-menu-direction" aria-hidden="true">{{
4424
+ option.arrow
4425
+ }}</span>
4426
+ }
4427
+ </button>
4428
+ }
4429
+
4430
+ @if (!sortIsDefault()) {
4431
+ <!-- Only while there is an override to undo: at the default there is nothing to
4432
+ reset, and a row that does nothing teaches people to stop reading the menu. -->
4433
+ <button
4434
+ type="button"
4435
+ role="menuitem"
4436
+ class="filter-add-option sort-menu-reset"
4437
+ data-testid="sort-menu-reset"
4438
+ (click)="sortReset.emit(); sortMenu.hide()"
4439
+ >
4440
+ {{ t('ResetSort') }}
4441
+ </button>
4442
+ }
4443
+ </div>
4444
+ </p-popover>
4445
+
4341
4446
  <p-popover #addMenu (onHide)="isAddOpen.set(false); addSearch.set('')">
4342
4447
  <div class="filter-add-menu" role="menu">
4343
4448
  <input
@@ -4464,7 +4569,7 @@ class SpiderlyFilterBarComponent {
4464
4569
  </div>
4465
4570
  }
4466
4571
  </ng-container>
4467
- `, isInline: true, styles: [":host{display:block}.filter-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 0 0}.filter-chip{display:inline-flex;align-items:center;gap:2px;border:1px solid var(--p-content-border-color);border-radius:999px;background:var(--p-content-background);padding:0;font-size:.875rem;line-height:1.4}.filter-chip-edit{border:none;background:none;color:inherit;font:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:4px;padding:4px 4px 4px 10px;border-radius:999px 0 0 999px}.filter-chip-edit:hover .filter-chip-value{text-decoration:underline}.filter-chip-label{color:var(--p-text-muted-color)}.filter-chip-value{font-weight:600}.filter-chip-remove{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);font-size:1.05rem;line-height:1;padding:4px 9px 5px 4px;border-radius:0 999px 999px 0}.filter-chip-remove:hover{color:var(--p-primary-color)}.filter-add{flex:none}.filter-add-menu{display:flex;flex-direction:column;align-items:stretch;gap:2px;min-width:14rem;max-height:20rem;overflow-y:auto}.filter-add-search{margin-bottom:4px;position:sticky;top:0}.filter-add-option{border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.filter-add-option:hover{background:var(--p-content-hover-background)}.filter-editor{display:flex;margin-top:8px;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid var(--p-content-border-color);border-radius:var(--p-content-border-radius);background:var(--p-content-background);padding:8px}.filter-editor-label{font-weight:600}.filter-editor-tick{display:inline-flex;align-items:center;gap:4px;cursor:pointer}.filter-bar-count{color:var(--p-text-muted-color);font-size:.875rem;margin-left:4px}.sort-chip{display:inline-flex;align-items:center;gap:4px;color:var(--p-text-muted-color);font-size:.875rem}.sort-chip-key{color:var(--p-text-color)}.filter-bar-clear{margin-left:auto;flex:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i3$3.ButtonDirective, selector: "[pButton]", inputs: ["iconPos", "loadingIcon", "loading", "severity", "raised", "rounded", "text", "outlined", "size", "plain", "fluid", "label", "icon", "buttonProps"] }, { kind: "ngmodule", type: CheckboxModule }, { kind: "component", type: i4$2.Checkbox, selector: "p-checkbox, p-checkBox, p-check-box", inputs: ["value", "name", "disabled", "binary", "ariaLabelledBy", "ariaLabel", "tabindex", "inputId", "style", "inputStyle", "styleClass", "inputClass", "indeterminate", "size", "formControl", "checkboxIcon", "readonly", "required", "autofocus", "trueValue", "falseValue", "variant"], outputs: ["onChange", "onFocus", "onBlur"] }, { kind: "ngmodule", type: DatePickerModule }, { kind: "component", type: i4$1.DatePicker, selector: "p-datePicker, p-datepicker, p-date-picker", inputs: ["iconDisplay", "style", "styleClass", "inputStyle", "inputId", "name", "inputStyleClass", "placeholder", "ariaLabelledBy", "ariaLabel", "iconAriaLabel", "disabled", "dateFormat", "multipleSeparator", "rangeSeparator", "inline", "showOtherMonths", "selectOtherMonths", "showIcon", "fluid", "icon", "appendTo", "readonlyInput", "shortYearCutoff", "monthNavigator", "yearNavigator", "hourFormat", "timeOnly", "stepHour", "stepMinute", "stepSecond", "showSeconds", "required", "showOnFocus", "showWeek", "startWeekFromFirstDayOfYear", "showClear", "dataType", "selectionMode", "maxDateCount", "showButtonBar", "todayButtonStyleClass", "clearButtonStyleClass", "autofocus", "autoZIndex", "baseZIndex", "panelStyleClass", "panelStyle", "keepInvalid", "hideOnDateTimeSelect", "touchUI", "timeSeparator", "focusTrap", "showTransitionOptions", "hideTransitionOptions", "tabindex", "variant", "size", "minDate", "maxDate", "disabledDates", "disabledDays", "yearRange", "showTime", "responsiveOptions", "numberOfMonths", "firstDayOfWeek", "locale", "view", "defaultDate"], outputs: ["onFocus", "onBlur", "onClose", "onSelect", "onClear", "onInput", "onTodayClick", "onClearClick", "onMonthChange", "onYearChange", "onClickOutside", "onShow"] }, { kind: "ngmodule", type: InputTextModule }, { kind: "directive", type: i5$1.InputText, selector: "[pInputText]", inputs: ["variant", "fluid", "pSize"] }, { kind: "ngmodule", type: MultiSelectModule }, { kind: "component", type: i7.MultiSelect, selector: "p-multiSelect, p-multiselect, p-multi-select", inputs: ["id", "ariaLabel", "style", "styleClass", "panelStyle", "panelStyleClass", "inputId", "disabled", "fluid", "readonly", "group", "filter", "filterPlaceHolder", "filterLocale", "overlayVisible", "tabindex", "variant", "appendTo", "dataKey", "name", "ariaLabelledBy", "displaySelectedLabel", "maxSelectedLabels", "selectionLimit", "selectedItemsLabel", "showToggleAll", "emptyFilterMessage", "emptyMessage", "resetFilterOnHide", "dropdownIcon", "chipIcon", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "showHeader", "filterBy", "scrollHeight", "lazy", "virtualScroll", "loading", "virtualScrollItemSize", "loadingIcon", "virtualScrollOptions", "overlayOptions", "ariaFilterLabel", "filterMatchMode", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "autofocusFilter", "display", "autocomplete", "size", "showClear", "autofocus", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "defaultLabel", "placeholder", "options", "filterValue", "itemSize", "selectAll", "focusOnHover", "filterFields", "selectOnFocus", "autoOptionFocus", "highlightOnSelect"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onClear", "onPanelShow", "onPanelHide", "onLazyLoad", "onRemove", "onSelectAllChange"] }, { kind: "ngmodule", type: PopoverModule }, { kind: "component", type: i8.Popover, selector: "p-popover", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions"], outputs: ["onShow", "onHide"] }, { kind: "ngmodule", type: SelectModule }, { kind: "component", type: i3$1.Select, selector: "p-select", inputs: ["id", "scrollHeight", "filter", "name", "style", "panelStyle", "styleClass", "panelStyleClass", "readonly", "required", "editable", "appendTo", "tabindex", "placeholder", "loadingIcon", "filterPlaceholder", "filterLocale", "variant", "inputId", "dataKey", "filterBy", "filterFields", "autofocus", "resetFilterOnHide", "checkmark", "dropdownIcon", "loading", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "autoDisplayFirst", "group", "showClear", "emptyFilterMessage", "emptyMessage", "lazy", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "size", "overlayOptions", "ariaFilterLabel", "ariaLabel", "ariaLabelledBy", "filterMatchMode", "maxlength", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "focusOnHover", "selectOnFocus", "autoOptionFocus", "autofocusFilter", "fluid", "disabled", "itemSize", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "filterValue", "options"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onShow", "onHide", "onClear", "onLazyLoad"] }] }); }
4572
+ `, isInline: true, styles: [":host{display:block}.filter-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 0 0}.filter-chip{display:inline-flex;align-items:center;gap:2px;border:1px solid var(--p-content-border-color);border-radius:999px;background:var(--p-content-background);padding:0;font-size:.875rem;line-height:1.4}.filter-chip-edit{border:none;background:none;color:inherit;font:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:4px;padding:4px 4px 4px 10px;border-radius:999px 0 0 999px}.filter-chip-edit:hover .filter-chip-value{text-decoration:underline}.filter-chip-label{color:var(--p-text-muted-color)}.filter-chip-value{font-weight:600}.filter-chip-remove{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);font-size:1.05rem;line-height:1;padding:4px 9px 5px 4px;border-radius:0 999px 999px 0}.filter-chip-remove:hover{color:var(--p-primary-color)}.filter-add{flex:none}.filter-add-menu{display:flex;flex-direction:column;align-items:stretch;gap:2px;min-width:14rem;max-height:20rem;overflow-y:auto}.filter-add-search{margin-bottom:4px;position:sticky;top:0}.filter-add-option{border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.filter-add-option:hover{background:var(--p-content-hover-background)}.filter-editor{display:flex;margin-top:8px;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid var(--p-content-border-color);border-radius:var(--p-content-border-radius);background:var(--p-content-background);padding:8px}.filter-editor-label{font-weight:600}.filter-editor-tick{display:inline-flex;align-items:center;gap:4px;cursor:pointer}.filter-bar-count{color:var(--p-text-muted-color);font-size:.875rem;margin-left:4px}.sort-chip{display:inline-flex;align-items:center;gap:4px;color:var(--p-text-muted-color);font-size:.875rem}button.sort-chip{border:none;background:none;color:inherit;font:inherit;padding:0;cursor:pointer}button.sort-chip:hover .sort-chip-key,button.sort-chip:hover .sort-chip-label{text-decoration:underline}.sort-chip-caret{font-size:.65rem}.sort-chip-key{color:var(--p-text-color)}.sort-menu-option{display:flex;justify-content:space-between;align-items:center;gap:12px}.sort-menu-direction{color:var(--p-primary-color)}.sort-menu-reset{border-top:1px solid var(--p-content-border-color);border-radius:0;color:var(--p-text-muted-color)}.filter-bar-clear{margin-left:auto;flex:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i3$3.ButtonDirective, selector: "[pButton]", inputs: ["iconPos", "loadingIcon", "loading", "severity", "raised", "rounded", "text", "outlined", "size", "plain", "fluid", "label", "icon", "buttonProps"] }, { kind: "ngmodule", type: CheckboxModule }, { kind: "component", type: i4$2.Checkbox, selector: "p-checkbox, p-checkBox, p-check-box", inputs: ["value", "name", "disabled", "binary", "ariaLabelledBy", "ariaLabel", "tabindex", "inputId", "style", "inputStyle", "styleClass", "inputClass", "indeterminate", "size", "formControl", "checkboxIcon", "readonly", "required", "autofocus", "trueValue", "falseValue", "variant"], outputs: ["onChange", "onFocus", "onBlur"] }, { kind: "ngmodule", type: DatePickerModule }, { kind: "component", type: i4$1.DatePicker, selector: "p-datePicker, p-datepicker, p-date-picker", inputs: ["iconDisplay", "style", "styleClass", "inputStyle", "inputId", "name", "inputStyleClass", "placeholder", "ariaLabelledBy", "ariaLabel", "iconAriaLabel", "disabled", "dateFormat", "multipleSeparator", "rangeSeparator", "inline", "showOtherMonths", "selectOtherMonths", "showIcon", "fluid", "icon", "appendTo", "readonlyInput", "shortYearCutoff", "monthNavigator", "yearNavigator", "hourFormat", "timeOnly", "stepHour", "stepMinute", "stepSecond", "showSeconds", "required", "showOnFocus", "showWeek", "startWeekFromFirstDayOfYear", "showClear", "dataType", "selectionMode", "maxDateCount", "showButtonBar", "todayButtonStyleClass", "clearButtonStyleClass", "autofocus", "autoZIndex", "baseZIndex", "panelStyleClass", "panelStyle", "keepInvalid", "hideOnDateTimeSelect", "touchUI", "timeSeparator", "focusTrap", "showTransitionOptions", "hideTransitionOptions", "tabindex", "variant", "size", "minDate", "maxDate", "disabledDates", "disabledDays", "yearRange", "showTime", "responsiveOptions", "numberOfMonths", "firstDayOfWeek", "locale", "view", "defaultDate"], outputs: ["onFocus", "onBlur", "onClose", "onSelect", "onClear", "onInput", "onTodayClick", "onClearClick", "onMonthChange", "onYearChange", "onClickOutside", "onShow"] }, { kind: "ngmodule", type: InputTextModule }, { kind: "directive", type: i5$1.InputText, selector: "[pInputText]", inputs: ["variant", "fluid", "pSize"] }, { kind: "ngmodule", type: MultiSelectModule }, { kind: "component", type: i7.MultiSelect, selector: "p-multiSelect, p-multiselect, p-multi-select", inputs: ["id", "ariaLabel", "style", "styleClass", "panelStyle", "panelStyleClass", "inputId", "disabled", "fluid", "readonly", "group", "filter", "filterPlaceHolder", "filterLocale", "overlayVisible", "tabindex", "variant", "appendTo", "dataKey", "name", "ariaLabelledBy", "displaySelectedLabel", "maxSelectedLabels", "selectionLimit", "selectedItemsLabel", "showToggleAll", "emptyFilterMessage", "emptyMessage", "resetFilterOnHide", "dropdownIcon", "chipIcon", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "showHeader", "filterBy", "scrollHeight", "lazy", "virtualScroll", "loading", "virtualScrollItemSize", "loadingIcon", "virtualScrollOptions", "overlayOptions", "ariaFilterLabel", "filterMatchMode", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "autofocusFilter", "display", "autocomplete", "size", "showClear", "autofocus", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "defaultLabel", "placeholder", "options", "filterValue", "itemSize", "selectAll", "focusOnHover", "filterFields", "selectOnFocus", "autoOptionFocus", "highlightOnSelect"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onClear", "onPanelShow", "onPanelHide", "onLazyLoad", "onRemove", "onSelectAllChange"] }, { kind: "ngmodule", type: PopoverModule }, { kind: "component", type: i8.Popover, selector: "p-popover", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions"], outputs: ["onShow", "onHide"] }, { kind: "ngmodule", type: SelectModule }, { kind: "component", type: i3$1.Select, selector: "p-select", inputs: ["id", "scrollHeight", "filter", "name", "style", "panelStyle", "styleClass", "panelStyleClass", "readonly", "required", "editable", "appendTo", "tabindex", "placeholder", "loadingIcon", "filterPlaceholder", "filterLocale", "variant", "inputId", "dataKey", "filterBy", "filterFields", "autofocus", "resetFilterOnHide", "checkmark", "dropdownIcon", "loading", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "autoDisplayFirst", "group", "showClear", "emptyFilterMessage", "emptyMessage", "lazy", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "size", "overlayOptions", "ariaFilterLabel", "ariaLabel", "ariaLabelledBy", "filterMatchMode", "maxlength", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "focusOnHover", "selectOnFocus", "autoOptionFocus", "autofocusFilter", "fluid", "disabled", "itemSize", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "filterValue", "options"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onShow", "onHide", "onClear", "onLazyLoad"] }] }); }
4468
4573
  }
4469
4574
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyFilterBarComponent, decorators: [{
4470
4575
  type: Component,
@@ -4522,23 +4627,49 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
4522
4627
  </span>
4523
4628
  }
4524
4629
 
4525
- @if (sort().length) {
4526
- <!-- Read-only, and that is the scope on purpose. Multi-sort is invisible today (sort
4527
- icons scattered over headers, nothing saying which is primary), so saying it in one
4528
- place is the whole gain. An x here would have to mean "back to the DEFAULT sort"
4529
- rather than "no sort", because applyDefaultSortIfUnsorted puts the default straight
4530
- back — an affordance that lies is worse than none. -->
4630
+ @if (sortOptions().length) {
4631
+ <!-- The chip is the PICKER's trigger (2026-09-06, grilled) — Polaris's always-present
4632
+ SortButton, in the slot the read-only chip held. Always rendered, sorted or not:
4633
+ with the trigger only on an active sort, an unsorted grid would have no way to
4634
+ sort by a hidden column, which is the capability the picker exists for. It
4635
+ replaced a read-only chip whose stated rationale ("an x would lie — the default
4636
+ snaps back") inverted the day a viewed table had no default: the operator was
4637
+ left with an inherited sort, no chip x, no clear button, and the one header that
4638
+ could un-sort it hidden. -->
4639
+ <button
4640
+ type="button"
4641
+ class="sort-chip"
4642
+ data-testid="sort-chip"
4643
+ [attr.aria-expanded]="isSortOpen()"
4644
+ (click)="sortMenu.toggle($event); isSortOpen.set(!isSortOpen())"
4645
+ >
4646
+ <ng-container *ngTemplateOutlet="sortChipBody"></ng-container>
4647
+ <i class="pi pi-chevron-down sort-chip-caret" aria-hidden="true"></i>
4648
+ </button>
4649
+ } @else if (sort().length) {
4650
+ <!-- No options handed over — the chip stays what it was: one read-only place naming
4651
+ the ordering and which key is primary. -->
4531
4652
  <span class="sort-chip" data-testid="sort-chip">
4653
+ <ng-container *ngTemplateOutlet="sortChipBody"></ng-container>
4654
+ </span>
4655
+ }
4656
+
4657
+ <!-- One home for the chip's sentence, whichever element renders it — the read-only
4658
+ branch never reaches the @else (it is gated on sort().length). -->
4659
+ <ng-template #sortChipBody>
4660
+ @if (sort().length) {
4532
4661
  <span class="sort-chip-label">{{ t('SortedBy') }}</span>
4533
- @for (key of sort(); track key.label; let last = $last) {
4662
+ @for (key of sort(); track key.field; let last = $last) {
4534
4663
  <span class="sort-chip-key"
4535
4664
  >{{ key.label }} {{ key.descending ? '↓' : '↑' }}{{
4536
4665
  last ? '' : ','
4537
4666
  }}</span
4538
4667
  >
4539
4668
  }
4540
- </span>
4541
- }
4669
+ } @else {
4670
+ <span class="sort-chip-label">{{ t('Sort') }}</span>
4671
+ }
4672
+ </ng-template>
4542
4673
 
4543
4674
  @if (totalRecords() !== null) {
4544
4675
  <!-- What came back, next to the chips that say what was asked. An empty grid under a
@@ -4570,8 +4701,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
4570
4701
  @if (filters().applied().length) {
4571
4702
  <!-- The bar owns clearing now: the toolbar's button sat a row away from the chips it
4572
4703
  cleared, which is two affordances for one job. Emits rather than clearing the store
4573
- itself, because whoever owns the query also owns the persisted state and the sort
4574
- that a full clear has to reach. -->
4704
+ itself, because whoever owns the query owns the requery and the persisted snapshot
4705
+ that follow a clear. Filters only — sort is its own channel (the picker above). -->
4575
4706
  <button
4576
4707
  pButton
4577
4708
  type="button"
@@ -4583,6 +4714,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
4583
4714
  ></button>
4584
4715
  }
4585
4716
 
4717
+ <p-popover #sortMenu (onHide)="isSortOpen.set(false)">
4718
+ <div class="filter-add-menu" role="menu">
4719
+ @for (option of sortRows(); track option.field) {
4720
+ <!-- Click sorts ascending; clicking the already-ascending option flips it. One
4721
+ gesture, look, one gesture back — the header's cycle without needing the
4722
+ header, so a hidden column's ordering is reachable too. -->
4723
+ <button
4724
+ type="button"
4725
+ role="menuitem"
4726
+ class="filter-add-option sort-menu-option"
4727
+ data-testid="sort-menu-option"
4728
+ (click)="pickSort(option); sortMenu.hide()"
4729
+ >
4730
+ <!-- &ngsp; keeps a real space between label and arrow — same collapse the
4731
+ filter chip documents: without it the copied/read text is "Naziv↓". -->
4732
+ <span>{{ option.label }}</span>&ngsp;
4733
+ @if (option.arrow) {
4734
+ <span class="sort-menu-direction" aria-hidden="true">{{
4735
+ option.arrow
4736
+ }}</span>
4737
+ }
4738
+ </button>
4739
+ }
4740
+
4741
+ @if (!sortIsDefault()) {
4742
+ <!-- Only while there is an override to undo: at the default there is nothing to
4743
+ reset, and a row that does nothing teaches people to stop reading the menu. -->
4744
+ <button
4745
+ type="button"
4746
+ role="menuitem"
4747
+ class="filter-add-option sort-menu-reset"
4748
+ data-testid="sort-menu-reset"
4749
+ (click)="sortReset.emit(); sortMenu.hide()"
4750
+ >
4751
+ {{ t('ResetSort') }}
4752
+ </button>
4753
+ }
4754
+ </div>
4755
+ </p-popover>
4756
+
4586
4757
  <p-popover #addMenu (onHide)="isAddOpen.set(false); addSearch.set('')">
4587
4758
  <div class="filter-add-menu" role="menu">
4588
4759
  <input
@@ -4709,7 +4880,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
4709
4880
  </div>
4710
4881
  }
4711
4882
  </ng-container>
4712
- `, styles: [":host{display:block}.filter-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 0 0}.filter-chip{display:inline-flex;align-items:center;gap:2px;border:1px solid var(--p-content-border-color);border-radius:999px;background:var(--p-content-background);padding:0;font-size:.875rem;line-height:1.4}.filter-chip-edit{border:none;background:none;color:inherit;font:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:4px;padding:4px 4px 4px 10px;border-radius:999px 0 0 999px}.filter-chip-edit:hover .filter-chip-value{text-decoration:underline}.filter-chip-label{color:var(--p-text-muted-color)}.filter-chip-value{font-weight:600}.filter-chip-remove{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);font-size:1.05rem;line-height:1;padding:4px 9px 5px 4px;border-radius:0 999px 999px 0}.filter-chip-remove:hover{color:var(--p-primary-color)}.filter-add{flex:none}.filter-add-menu{display:flex;flex-direction:column;align-items:stretch;gap:2px;min-width:14rem;max-height:20rem;overflow-y:auto}.filter-add-search{margin-bottom:4px;position:sticky;top:0}.filter-add-option{border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.filter-add-option:hover{background:var(--p-content-hover-background)}.filter-editor{display:flex;margin-top:8px;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid var(--p-content-border-color);border-radius:var(--p-content-border-radius);background:var(--p-content-background);padding:8px}.filter-editor-label{font-weight:600}.filter-editor-tick{display:inline-flex;align-items:center;gap:4px;cursor:pointer}.filter-bar-count{color:var(--p-text-muted-color);font-size:.875rem;margin-left:4px}.sort-chip{display:inline-flex;align-items:center;gap:4px;color:var(--p-text-muted-color);font-size:.875rem}.sort-chip-key{color:var(--p-text-color)}.filter-bar-clear{margin-left:auto;flex:none}\n"] }]
4883
+ `, styles: [":host{display:block}.filter-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 0 0}.filter-chip{display:inline-flex;align-items:center;gap:2px;border:1px solid var(--p-content-border-color);border-radius:999px;background:var(--p-content-background);padding:0;font-size:.875rem;line-height:1.4}.filter-chip-edit{border:none;background:none;color:inherit;font:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:4px;padding:4px 4px 4px 10px;border-radius:999px 0 0 999px}.filter-chip-edit:hover .filter-chip-value{text-decoration:underline}.filter-chip-label{color:var(--p-text-muted-color)}.filter-chip-value{font-weight:600}.filter-chip-remove{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);font-size:1.05rem;line-height:1;padding:4px 9px 5px 4px;border-radius:0 999px 999px 0}.filter-chip-remove:hover{color:var(--p-primary-color)}.filter-add{flex:none}.filter-add-menu{display:flex;flex-direction:column;align-items:stretch;gap:2px;min-width:14rem;max-height:20rem;overflow-y:auto}.filter-add-search{margin-bottom:4px;position:sticky;top:0}.filter-add-option{border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.filter-add-option:hover{background:var(--p-content-hover-background)}.filter-editor{display:flex;margin-top:8px;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid var(--p-content-border-color);border-radius:var(--p-content-border-radius);background:var(--p-content-background);padding:8px}.filter-editor-label{font-weight:600}.filter-editor-tick{display:inline-flex;align-items:center;gap:4px;cursor:pointer}.filter-bar-count{color:var(--p-text-muted-color);font-size:.875rem;margin-left:4px}.sort-chip{display:inline-flex;align-items:center;gap:4px;color:var(--p-text-muted-color);font-size:.875rem}button.sort-chip{border:none;background:none;color:inherit;font:inherit;padding:0;cursor:pointer}button.sort-chip:hover .sort-chip-key,button.sort-chip:hover .sort-chip-label{text-decoration:underline}.sort-chip-caret{font-size:.65rem}.sort-chip-key{color:var(--p-text-color)}.sort-menu-option{display:flex;justify-content:space-between;align-items:center;gap:12px}.sort-menu-direction{color:var(--p-primary-color)}.sort-menu-reset{border-top:1px solid var(--p-content-border-color);border-radius:0;color:var(--p-text-muted-color)}.filter-bar-clear{margin-left:auto;flex:none}\n"] }]
4713
4884
  }] });
4714
4885
 
4715
4886
  class SpiderlyDeleteConfirmationComponent {
@@ -4754,6 +4925,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
4754
4925
  function rootFontSizePx() {
4755
4926
  return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
4756
4927
  }
4928
+ /** Order matters: `[a, b]` and `[b, a]` ask the server for different orderings. */
4929
+ function sortMetaEquals(a, b) {
4930
+ return (a.length === b.length &&
4931
+ a.every((m, i) => m.field === b[i].field && m.order === b[i].order));
4932
+ }
4757
4933
  const DEFAULT_COLUMN_WIDTH_REM = {
4758
4934
  text: 12,
4759
4935
  date: 10,
@@ -4852,7 +5028,8 @@ class SpiderlyDataTableComponent {
4852
5028
  /** Paginator page-size choices; merge rules and persistence: CLAUDE.md → "Rows-per-page". */
4853
5029
  this.rowsPerPageOptions = [10, 25, 50, 100];
4854
5030
  /**
4855
- * The view currently selected. Seeded from the first view in `ngOnInit`, never left null on a
5031
+ * The view currently selected. Seeded from the first view in `ngOnInit` — then overridden by
5032
+ * `restoreActiveView`, so a reload lands where the operator was — and never left null on a
4856
5033
  * table that has views: `viewScope` keys the layout off it, so an unseeded table writes its
4857
5034
  * width and order to the SAME key an unmigrated table uses — orphaned the moment the operator
4858
5035
  * clicks the first tab — and no tab reads as selected until they do.
@@ -4971,6 +5148,18 @@ class SpiderlyDataTableComponent {
4971
5148
  * `tableStyle` is a field.
4972
5149
  */
4973
5150
  this.sortKeys = [];
5151
+ /**
5152
+ * Gates the picker's reset row. Refreshed with `sortKeys` — same request, same reason it is a
5153
+ * field rather than a getter. "Default" is view-aware (`defaultMultiSortMeta`), so inside a
5154
+ * view the row offers the view's ordering back, not the table's.
5155
+ */
5156
+ this.sortIsDefault = true;
5157
+ /**
5158
+ * What the bar's picker offers: every sortable data column, hidden ones INCLUDED — the picker
5159
+ * exists so an ordering does not need its column on screen. Built once in `ngOnInit` (columns
5160
+ * are a declaration, not state) and labelled here because the bar knows nothing about columns.
5161
+ */
5162
+ this.sortPickOptions = [];
4974
5163
  }
4975
5164
  ngAfterViewInit() {
4976
5165
  this.setupRemovableSort();
@@ -5000,29 +5189,31 @@ class SpiderlyDataTableComponent {
5000
5189
  if (sortMeta && sortMeta.order === -1) {
5001
5190
  const mouseEvent = event.originalEvent;
5002
5191
  const isMultiSortClick = mouseEvent.metaKey || mouseEvent.ctrlKey;
5003
- // Un-sorting substitutes the declared default (when there is one) right here,
5004
- // so sortMultiple() broadcasts and emits the final meta in one shot.
5005
5192
  if (isMultiSortClick) {
5193
+ // Removing ONE level of a multi-sort keeps the reader's place (PrimeNG's metaKey
5194
+ // path never pages either), so this cannot ride applySort, whose page reset is
5195
+ // half the point. The substituted default keeps sortMultiple() to one emission.
5006
5196
  const remaining = this.table._multiSortMeta.filter((m) => m.field !== event.field);
5007
5197
  this.table._multiSortMeta = remaining.length
5008
5198
  ? remaining
5009
5199
  : (this.defaultMultiSortMeta() ?? remaining);
5200
+ this.table.sortMultiple();
5201
+ if (this.table.isStateful())
5202
+ this.table.saveState();
5203
+ this.table.anchorRowIndex = null;
5204
+ this.persistActiveViewSort();
5010
5205
  }
5011
5206
  else {
5012
- this.table._multiSortMeta = this.defaultMultiSortMeta() ?? [];
5013
- if (this.table.resetPageOnSort) {
5014
- this.table._first = 0;
5015
- this.table.firstChange.emit(0);
5016
- }
5017
- }
5018
- this.table.sortMultiple();
5019
- if (this.table.isStateful()) {
5020
- this.table.saveState();
5207
+ // A single-click un-sort IS "back to the default" — the exact gesture the bar's
5208
+ // reset row asks for, so it uses the same body.
5209
+ this.resetSortFromBar();
5021
5210
  }
5022
- this.table.anchorRowIndex = null;
5023
- return;
5024
5211
  }
5025
- originalSort(event);
5212
+ else {
5213
+ originalSort(event);
5214
+ // PrimeNG's sort() pages/saves itself; the override slot is ours to record.
5215
+ this.persistActiveViewSort();
5216
+ }
5026
5217
  };
5027
5218
  }
5028
5219
  /**
@@ -5048,12 +5239,21 @@ class SpiderlyDataTableComponent {
5048
5239
  }
5049
5240
  }
5050
5241
  }
5051
- ngOnChanges() {
5242
+ ngOnChanges(changes) {
5052
5243
  // Only meaningful once ngOnInit has resolved the defaults; before that it is a no-op re-run.
5053
5244
  if (this.rows != null)
5054
5245
  this.mergeActivePageSizesIntoOptions();
5246
+ if (changes['views'])
5247
+ this.adoptStoredView();
5055
5248
  }
5056
5249
  ngOnInit() {
5250
+ // Fail loud, not half-broken: the store commits through the paginated request, and a
5251
+ // client-side (form-array) table has none — armed anyway, the first commit would call the
5252
+ // absent getPaginatedListObservableMethod inside the requery effect and strand the pending
5253
+ // overlay forever. Client-side store predicates are unbuilt (spiderly#407 territory).
5254
+ if (this.filters && !this.hasLazyLoad) {
5255
+ throw new Error('spiderly-data-table: [filters] requires a lazy table — client-side filtering through a store is not implemented. Remove [filters] or make the table lazy.');
5256
+ }
5057
5257
  if (this.rows == null)
5058
5258
  this.rows = this.configService.defaultPageSize;
5059
5259
  this.activeViewId ??= this.views?.[0]?.id ?? null;
@@ -5071,23 +5271,38 @@ class SpiderlyDataTableComponent {
5071
5271
  this.clientLoad();
5072
5272
  }
5073
5273
  this.mergeActivePageSizesIntoOptions();
5274
+ // AFTER `resolvedStateKey` is derived, which is what every restore below keys off — placed
5275
+ // above it they read a null key and silently restore nothing. The view restore runs FIRST,
5276
+ // because the other three read the `viewScope` it decides. And all of it before the effect
5277
+ // is armed, so the first request already carries the filters and the effect's skipped first
5278
+ // run sees the state it will be watching.
5279
+ const restoredView = this.restoreActiveView();
5074
5280
  this.restoreColumnVisibility();
5075
5281
  this.restoreColumnLayout();
5076
- // AFTER `resolvedStateKey` is derived, which is what both of these key off — placed above it
5077
- // first, they read a null key and silently restored nothing. And before the effect is armed,
5078
- // so the first request already carries the filters and the effect's skipped first run sees
5079
- // the state it will be watching.
5080
- this.restoreAppliedFilters();
5282
+ this.enterViewFilters(restoredView, 'refresh');
5081
5283
  if (this.filters)
5082
5284
  this.requeryOnAppliedFilters();
5083
5285
  this.chooserCols = this.cols.filter(SpiderlyDataTableComponent.isDataColumn);
5084
5286
  this.refreshVisibleCols();
5287
+ // From chooserCols, not cols: same "every data column, hidden included" set the chooser
5288
+ // offers — one derivation of that list, so the picker cannot drift from it.
5289
+ this.sortPickOptions = this.chooserCols
5290
+ .filter((col) => this.isColumnSortable(col))
5291
+ .map((col) => ({ field: col.field, label: col.name }));
5085
5292
  // Bound to p-table's [multiSortMeta] so the FIRST request already carries the right
5086
- // sort. Persisted user sort wins over the declared default; we read it ourselves
5087
- // because PrimeNG's restoreState() runs on the first [value] change — after the
5088
- // initial lazy emit has already left.
5089
- this.initialMultiSortMeta =
5090
- this.persistedMultiSortMeta() ?? this.defaultMultiSortMeta();
5293
+ // sort. On a viewed table the persisted table-global sort is a NON-SOURCE (2026-09-06):
5294
+ // sort is per-view state (override ?? declared ?? table default), and the blob — which
5295
+ // holds the last ordering ANY view used — would re-impose across views the exact
5296
+ // carry-over the per-view model removes, on every F5. It still carries rows/first there,
5297
+ // and stays the one sort memory of a view-less table. We read it all ourselves because
5298
+ // PrimeNG's restoreState() runs on the first [value] change — after the initial lazy emit
5299
+ // has already left — and is then told to agree (see `alignPersistedTableStateSort`).
5300
+ const tableState = this.persistedTableState();
5301
+ this.initialMultiSortMeta = this.views?.length
5302
+ ? this.resolvedViewSort()
5303
+ : (this.sanitizedSortMeta(tableState?.multiSortMeta) ??
5304
+ this.tableDefaultMultiSortMeta());
5305
+ this.alignPersistedTableStateSort(tableState);
5091
5306
  }
5092
5307
  /** A data column shows values for a `field`; anything else (the actions column) always renders. */
5093
5308
  static isDataColumn(col) {
@@ -5225,24 +5440,48 @@ class SpiderlyDataTableComponent {
5225
5440
  }
5226
5441
  this.persistColumnVisibility();
5227
5442
  this.refreshVisibleCols();
5443
+ if (!visible)
5444
+ this.dropHiddenColumnSort([col]);
5228
5445
  }
5229
5446
  /**
5230
5447
  * Restores every column to its declared default and forgets the stored overrides — visibility,
5231
5448
  * wrap, order and width alike. It undid visibility only until the header menu shipped three
5232
5449
  * more gestures beside it, and a layout with no way back is worse than one with no knobs.
5233
5450
  *
5234
- * Hiding — here or from the menu — never touches filters or sort: the chip bar names every
5235
- * applied filter and the sort chip names the sort, hidden column or not (decision 2b). The
5236
- * legacy "hidden contributes nothing" apparatus died with the header-filter path.
5451
+ * On a STORE table, hiding — here or from the menu — never touches filters or sort: the chip
5452
+ * bar names every applied filter and the sort chip names the sort, hidden column or not
5453
+ * (decision 2b). A storeless table keeps the sort half of the legacy invariant instead — see
5454
+ * `dropHiddenColumnSort`.
5237
5455
  */
5238
5456
  resetColumnLayout() {
5239
5457
  this.columnWrap = {};
5240
5458
  this.columnOrder = [];
5241
5459
  this.columnWidths = {};
5242
5460
  this.persistColumnLayout();
5461
+ const wasVisible = this.visibleCols.filter(SpiderlyDataTableComponent.isDataColumn);
5243
5462
  this.columnVisibilityOverrides = {};
5244
5463
  this.persistColumnVisibility();
5245
5464
  this.refreshVisibleCols();
5465
+ // Columns the reset just hid follow the same rule as a manual hide.
5466
+ this.dropHiddenColumnSort(wasVisible.filter((col) => !this.isColumnVisible(col)));
5467
+ }
5468
+ /**
5469
+ * The sort half of the legacy "hidden contributes nothing" invariant, kept ONLY for storeless
5470
+ * tables (the generated M2M details grids, until spiderly#407 scaffolds their stores): they
5471
+ * have no sort chip, so a hidden column's sort would order the grid with nothing on screen
5472
+ * naming it and no control left to clear it. The filter half needs no heir — `lazyLoad`
5473
+ * overwrites the outgoing filters unconditionally. On a store table this is a no-op:
5474
+ * decision 2b's chips are the visible surface, hidden column or not.
5475
+ */
5476
+ dropHiddenColumnSort(cols) {
5477
+ if (!this.table || this.filters)
5478
+ return;
5479
+ const sorted = cols.filter((col) => this.table._multiSortMeta?.some((m) => m.field === col.field));
5480
+ if (!sorted.length)
5481
+ return;
5482
+ this.table._multiSortMeta = this.table._multiSortMeta.filter((m) => !sorted.some((col) => col.field === m.field));
5483
+ this.table.tableService.onSort(this.table._multiSortMeta);
5484
+ this.table._filter(); // re-emits the lazy load and saves the cleaned state
5246
5485
  }
5247
5486
  /**
5248
5487
  * Layout keys carry the ACTIVE VIEW, which is what makes a view more than a saved filter: a
@@ -5271,6 +5510,65 @@ class SpiderlyDataTableComponent {
5271
5510
  get filterStorage() {
5272
5511
  return this.stateStorage === 'local' ? localStorage : sessionStorage;
5273
5512
  }
5513
+ /**
5514
+ * Where the active view id lives. Deliberately NOT view-scoped — it is the input `viewScope`
5515
+ * derives from — and it follows `stateStorage` like the filters rather than the layout's
5516
+ * always-local rule, because the id and the filters it scopes are one question: a durable id
5517
+ * over session-scoped filters would restore a tab with nothing under it.
5518
+ */
5519
+ get viewStateKey() {
5520
+ return this.resolvedStateKey ? `${this.resolvedStateKey}:view` : null;
5521
+ }
5522
+ /** Only `selectView` writes; the `ngOnInit` seed does not — absence means "the default". */
5523
+ persistActiveView() {
5524
+ if (!this.viewStateKey || !this.activeViewId)
5525
+ return;
5526
+ writeStoredJson(this.filterStorage, this.viewStateKey, this.activeViewId);
5527
+ }
5528
+ /**
5529
+ * Puts a reload back on the view the operator was using. Every persisted key carries the view
5530
+ * segment, so before this existed the seed decided what got restored: F5 landed on the first
5531
+ * view and read `…:all:filters` — the tab snapped to "Sve" and the bar came back empty while
5532
+ * the operator's filters sat intact under the old view's segment (Filip, on /porudzbine).
5533
+ *
5534
+ * An id naming no current view keeps the seed AND the stored key: "vanished by deploy" and
5535
+ * "not yet arrived" are indistinguishable here (order-list's catalog-licensed "Za pakovanje"
5536
+ * is literally both), so `adoptStoredView` re-reads the key on every `[views]` change and the
5537
+ * key self-heals on the next explicit click.
5538
+ */
5539
+ restoreActiveView() {
5540
+ if (!this.viewStateKey || !this.views?.length)
5541
+ return null;
5542
+ const storedId = readStoredJson(this.filterStorage, this.viewStateKey);
5543
+ if (typeof storedId !== 'string')
5544
+ return null;
5545
+ const view = this.viewById(storedId) ?? null;
5546
+ if (view)
5547
+ this.activeViewId = view.id;
5548
+ return view;
5549
+ }
5550
+ /**
5551
+ * The second half of `restoreActiveView`: a stored view that did not exist at init is adopted
5552
+ * when a later `[views]` change brings it — through `selectView`, so it gets the same
5553
+ * stored-wins/transient/layout/requery semantics a click would have had. There is no parked
5554
+ * copy of the stored id — the key itself is re-read, and because every explicit pick
5555
+ * overwrites it, the operator's own choice wins by construction. Accepted cost of adopting
5556
+ * late instead of holding the first request: a brief flash of the seeded view's rows and a
5557
+ * second request when the late view lands.
5558
+ */
5559
+ adoptStoredView() {
5560
+ if (!this.viewStateKey)
5561
+ return;
5562
+ const storedId = readStoredJson(this.filterStorage, this.viewStateKey);
5563
+ if (typeof storedId !== 'string' || storedId === this.activeViewId)
5564
+ return;
5565
+ const view = this.viewById(storedId);
5566
+ if (view)
5567
+ this.selectView(view);
5568
+ }
5569
+ viewById(id) {
5570
+ return this.views?.find((candidate) => candidate.id === id);
5571
+ }
5274
5572
  persistAppliedFilters() {
5275
5573
  if (!this.filtersStateKey || !this.filters)
5276
5574
  return;
@@ -5282,12 +5580,27 @@ class SpiderlyDataTableComponent {
5282
5580
  writeStoredJson(this.filterStorage, this.filtersStateKey, snapshot);
5283
5581
  }
5284
5582
  }
5285
- restoreAppliedFilters() {
5286
- if (!this.filtersStateKey || !this.filters)
5583
+ /**
5584
+ * Applies the right filters for entering `view` — the transient rule's one home. A TRANSIENT
5585
+ * view's `apply` is a function of NOW, so it always re-derives; restoring would put
5586
+ * yesterday's date under a tab claiming "today" (`persistAppliedFilters` has no transient
5587
+ * guard, so storage holds the LAST answer). A non-transient view's own stored answer wins
5588
+ * over its `apply`. The semantics argument is the one deliberate difference between the
5589
+ * callers: a 'click' re-asks the view's question when nothing is stored, a 'refresh' returns
5590
+ * the empty state the operator left — they cleared it, and F5 must give the cleared state
5591
+ * back. On a table with no views, `view` is null and this is a plain restore of the
5592
+ * `persistAppliedFilters` snapshot.
5593
+ */
5594
+ enterViewFilters(view, semantics) {
5595
+ if (!this.filters)
5287
5596
  return;
5288
- const snapshot = readStoredJson(this.filterStorage, this.filtersStateKey);
5289
- if (snapshot)
5290
- this.filters.restore(snapshot);
5597
+ const stored = !view?.transient && this.filtersStateKey
5598
+ ? readStoredJson(this.filterStorage, this.filtersStateKey)
5599
+ : null;
5600
+ if (stored)
5601
+ this.filters.restore(stored);
5602
+ else if (view?.transient || semantics === 'click')
5603
+ view?.apply?.(this.filters);
5291
5604
  }
5292
5605
  get layoutStateKey() {
5293
5606
  return this.resolvedStateKey
@@ -5344,25 +5657,142 @@ class SpiderlyDataTableComponent {
5344
5657
  return null;
5345
5658
  return readStoredJson(this.stateStorage === 'local' ? localStorage : sessionStorage, this.resolvedStateKey);
5346
5659
  }
5660
+ activeView() {
5661
+ return this.activeViewId ? this.viewById(this.activeViewId) : undefined;
5662
+ }
5663
+ /**
5664
+ * Where the operator's per-view sort override lives. Null on a table without views — those
5665
+ * keep the pure PrimeNG sort persistence they always had — and null on a STORELESS table:
5666
+ * there "hidden contributes nothing" retires a hidden column's sort (`dropHiddenColumnSort`)
5667
+ * with no persist call at the hide site, so an override restored blind at the next init
5668
+ * would re-impose a sort no chip can name. Store tables — where every viewed consumer
5669
+ * lives — have the chip. Follows `stateStorage` like the filters and the view id: the sort
5670
+ * is part of the same question they scope.
5671
+ */
5672
+ get viewSortStateKey() {
5673
+ if (!this.views?.length || !this.filters)
5674
+ return null;
5675
+ return this.resolvedStateKey
5676
+ ? `${this.resolvedStateKey}${this.viewScope}:sort`
5677
+ : null;
5678
+ }
5679
+ /** The operator's stored sort for the active view, sanitized like any persisted sort. */
5680
+ persistedViewSort() {
5681
+ if (!this.viewSortStateKey)
5682
+ return null;
5683
+ const stored = readStoredJson(this.filterStorage, this.viewSortStateKey);
5684
+ return Array.isArray(stored) ? this.sanitizedSortMeta(stored) : null;
5685
+ }
5686
+ /**
5687
+ * THE resolution ladder, named once: the operator's stored override, else the declared
5688
+ * default (`defaultMultiSortMeta` = view's declared sort ?? table default). Null means
5689
+ * "unsorted" — callers append their own `?? []` / blob terms rather than re-spelling the
5690
+ * ladder, so a future term (a per-view default refinement, decision 10's columns half)
5691
+ * lands everywhere at once.
5692
+ */
5693
+ resolvedViewSort() {
5694
+ return this.persistedViewSort() ?? this.defaultMultiSortMeta();
5695
+ }
5696
+ /**
5697
+ * Records the current sort as the active view's override — called from the EXPLICIT sort
5698
+ * gestures only (header click, `applySort` — the menu and both bar entry points), never
5699
+ * from a load: a sort merely inherited at init must not crystallize into an override, or a
5700
+ * later change to the view's declared sort would stop flowing to operators who never
5701
+ * touched sort — the same flows-through rule `columnVisibilityOverrides` documents. A sort
5702
+ * equal to the declared default is stored as ABSENCE for the same reason.
5703
+ */
5704
+ persistActiveViewSort() {
5705
+ const key = this.viewSortStateKey;
5706
+ if (!key)
5707
+ return;
5708
+ const current = this.table._multiSortMeta ?? [];
5709
+ if (sortMetaEquals(current, this.defaultMultiSortMeta() ?? [])) {
5710
+ this.filterStorage.removeItem(key);
5711
+ }
5712
+ else {
5713
+ writeStoredJson(this.filterStorage, key, current);
5714
+ }
5715
+ }
5716
+ /**
5717
+ * Puts the entering view's sort on the grid — stored override, else declared sort, else the
5718
+ * table default, else UNSORTED. A view is a COMPLETE state, sort included (2026-09-06,
5719
+ * grilled — supersedes the "a view stating no preference keeps the ordering already there"
5720
+ * this first shipped with): the carried-over ordering read as a bug in its first live week,
5721
+ * when "Poslate"'s statusChangedAt rode onto "Sve", whose sorted column ships hidden —
5722
+ * read-only chip, no header to tri-state, no way out. Polaris IndexFilters is the model:
5723
+ * sort is part of a view's saved definition. Broadcast only — the caller owns the refetch,
5724
+ * because which request carries the new sort differs between the store and storeless paths.
5725
+ */
5726
+ applyViewSort() {
5727
+ const target = this.resolvedViewSort() ?? [];
5728
+ if (sortMetaEquals(target, this.table._multiSortMeta ?? []))
5729
+ return false;
5730
+ this.broadcastSort(target);
5731
+ return true;
5732
+ }
5733
+ /**
5734
+ * Rewrites the sort inside PrimeNG's persisted state to the one `ngOnInit` resolved. The two
5735
+ * can disagree only on a viewed table — PrimeNG's state is table-global while the resolved
5736
+ * sort is the view's — and PrimeNG's restoreState() applies its copy on the first [value]
5737
+ * change, AFTER the initial request already left with ours: without this line the grid's
5738
+ * arrows and every later request silently fall back to the other view's sort. Handed the
5739
+ * already-parsed state: `ngOnInit` reads the blob once for the whole init pass.
5740
+ */
5741
+ alignPersistedTableStateSort(state) {
5742
+ if (!this.views?.length || !this.resolvedStateKey || !state)
5743
+ return;
5744
+ const resolved = this.initialMultiSortMeta ?? [];
5745
+ if (sortMetaEquals(state.multiSortMeta ?? [], resolved))
5746
+ return;
5747
+ state.multiSortMeta = resolved.length ? resolved : undefined;
5748
+ writeStoredJson(this.filterStorage, this.resolvedStateKey, state);
5749
+ }
5347
5750
  //#endregion
5348
5751
  /**
5349
- * The declared default as PrimeNG sort meta, or null when none is declared. Applies even
5350
- * while its column is hidden: the sort chip names the ordering whether or not the column
5351
- * is on screen (decision 2b).
5752
+ * The DECLARED default in the current context: the active view's own sort when it declares
5753
+ * one, else the table-level `defaultSortField`. Every "fall back to the declared default"
5754
+ * site — tri-state un-sort, Clear filters, the unsorted-load safety net — reads this, so
5755
+ * un-sorting inside a view lands on the view's ordering, not the table's.
5352
5756
  */
5353
5757
  defaultMultiSortMeta() {
5758
+ return this.viewDeclaredMultiSortMeta() ?? this.tableDefaultMultiSortMeta();
5759
+ }
5760
+ /**
5761
+ * The active view's declared sort, sanitized: a non-sortable field would answer every load
5762
+ * with a 400 (`keepSortableMeta`'s rationale), so it is dropped rather than sent; a view
5763
+ * whose declaration sanitizes to nothing falls through to the table default.
5764
+ */
5765
+ viewDeclaredMultiSortMeta() {
5766
+ return this.sanitizedSortMeta(this.activeView()?.sort);
5767
+ }
5768
+ /**
5769
+ * The table-level declared default as PrimeNG sort meta, or null when none is declared. On a
5770
+ * store table it applies even while its column is hidden — the sort chip names the ordering
5771
+ * whether or not the column is on screen (decision 2b). A STORELESS table has no chip, so a
5772
+ * hidden default would order the grid with nothing on screen naming it; there the default
5773
+ * dies with the column, exactly as the legacy invariant had it (see `dropHiddenColumnSort`).
5774
+ */
5775
+ tableDefaultMultiSortMeta() {
5354
5776
  if (!this.defaultSortField)
5355
5777
  return null;
5356
5778
  const defaultSortCol = this.cols?.find((col) => col.field === this.defaultSortField);
5779
+ if (defaultSortCol &&
5780
+ !this.filters &&
5781
+ !this.isColumnVisible(defaultSortCol))
5782
+ return null;
5357
5783
  // A declared default on a non-sortable column is a consumer mistake the backend answers
5358
5784
  // with a 400 on every load; fall back to its implicit Id DESC instead (see keepSortableMeta).
5359
5785
  if (defaultSortCol && !this.isColumnSortable(defaultSortCol))
5360
5786
  return null;
5361
5787
  return [{ field: this.defaultSortField, order: this.defaultSortOrder }];
5362
5788
  }
5363
- persistedMultiSortMeta() {
5364
- const state = this.persistedTableState();
5365
- const sortable = this.keepSortableMeta(state?.multiSortMeta);
5789
+ /**
5790
+ * `keepSortableMeta` with "sanitized to nothing" surfaced as null — the rule every sort
5791
+ * SOURCE shares ("a sort that sanitizes to nothing is no sort"), so the resolution chains
5792
+ * can `??` past an empty answer instead of stopping on an empty array.
5793
+ */
5794
+ sanitizedSortMeta(multiSortMeta) {
5795
+ const sortable = this.keepSortableMeta(multiSortMeta);
5366
5796
  return sortable.length ? sortable : null;
5367
5797
  }
5368
5798
  /**
@@ -5380,12 +5810,11 @@ class SpiderlyDataTableComponent {
5380
5810
  return col == null || this.isColumnSortable(col);
5381
5811
  });
5382
5812
  }
5383
- // Safety net: any lazy load still leaving unsorted (Clear filters — PrimeNG's clear()
5384
- // nulls the sort and emits in one call — or stale persisted state) falls back to the
5385
- // declared default. Patching the event before it becomes the backend Filter avoids a
5386
- // second fetch; clear() broadcasts sort state to the header icons before emitting,
5387
- // hence the explicit re-broadcast. The tri-state un-sort path never gets here — it
5388
- // substitutes the default at its source in setupRemovableSort().
5813
+ // Safety net: any lazy load still leaving unsorted (stale persisted state; a viewless
5814
+ // table with no sort memory yet) falls back to the declared default. Patching the event
5815
+ // before it becomes the backend Filter avoids a second fetch; the re-broadcast keeps the
5816
+ // header icons agreeing with what was actually sent. The tri-state un-sort path never
5817
+ // gets here — it substitutes the default at its source in setupRemovableSort().
5389
5818
  applyDefaultSortIfUnsorted(event) {
5390
5819
  if (event.multiSortMeta?.length)
5391
5820
  return;
@@ -5393,10 +5822,13 @@ class SpiderlyDataTableComponent {
5393
5822
  if (defaultSort == null)
5394
5823
  return;
5395
5824
  event.multiSortMeta = defaultSort;
5396
- if (this.table) {
5397
- this.table._multiSortMeta = defaultSort;
5398
- this.table.tableService.onSort(defaultSort);
5399
- }
5825
+ if (this.table)
5826
+ this.broadcastSort(defaultSort);
5827
+ }
5828
+ /** Puts `meta` on the grid and tells the header icons — no fetch; the caller owns that. */
5829
+ broadcastSort(meta) {
5830
+ this.table._multiSortMeta = meta;
5831
+ this.table.tableService.onSort(meta);
5400
5832
  }
5401
5833
  onPageChange(container) {
5402
5834
  scrollElementIntoViewIfAboveViewport(container);
@@ -5553,30 +5985,40 @@ class SpiderlyDataTableComponent {
5553
5985
  /**
5554
5986
  * Switches to a view. Clears first, always: a view is a STATE rather than an addition, and two
5555
5987
  * of them composed produce a question nobody asked. The clear runs through the store directly
5556
- * rather than through `clear(table)`, which would also wipe the sort and the persisted state a
5557
- * view is entitled to keep.
5988
+ * rather than through `clear()`, whose requery effect would spend a fetch before the entering
5989
+ * view's filters are in place.
5558
5990
  */
5559
5991
  selectView(view) {
5560
5992
  this.activeViewId = view.id;
5993
+ // Always writes — including the first view, so a deliberate "Sve" during a deploy race
5994
+ // sticks, and so an explicit pick beats a stored id whose view has not arrived yet
5995
+ // (`adoptStoredView` compares against this key).
5996
+ this.persistActiveView();
5561
5997
  // The keys just changed under us, so the layout has to be re-read for the view being entered
5562
5998
  // — otherwise the one being left keeps rendering until something else forces a reload.
5563
5999
  this.restoreColumnVisibility();
5564
6000
  this.restoreColumnLayout();
5565
6001
  this.refreshVisibleCols();
5566
- if (!this.filters)
6002
+ // Sort is view state too (decision 10's sort half). Swapped BEFORE the filter transition so
6003
+ // the one request the requery effect sends carries both.
6004
+ const sortChanged = this.applyViewSort();
6005
+ if (!this.filters) {
6006
+ if (sortChanged)
6007
+ this.table.sortMultiple();
5567
6008
  return;
5568
- // The filter key moved with the view too, so this view's own stored answer wins over whatever
5569
- // the last one left applied; `apply` runs only when the view has nothing stored yet. A
5570
- // TRANSIENT view inverts that: its apply is a function of now, so it always re-derives —
5571
- // restoring would put yesterday's date under a tab claiming "today".
6009
+ }
6010
+ const appliedBefore = this.filters.applied();
6011
+ // The filter key moved with the view too; `enterViewFilters` holds the stored-wins and
6012
+ // transient rules.
5572
6013
  this.filters.clear();
5573
- const stored = !view.transient && this.filtersStateKey
5574
- ? readStoredJson(this.filterStorage, this.filtersStateKey)
5575
- : null;
5576
- if (stored)
5577
- this.filters.restore(stored);
5578
- else
5579
- view.apply?.(this.filters);
6014
+ this.enterViewFilters(view, 'click');
6015
+ // The requery effect runs only when the applied set actually changed — `clear()` on an
6016
+ // already-empty store writes nothing (two views sharing one filter answer are exactly that)
6017
+ // — so a sort-only switch fetches here. `applied` is a computed: reference equality across
6018
+ // the transition means none of its sources moved.
6019
+ if (sortChanged && this.filters.applied() === appliedBefore) {
6020
+ this.refetchFromFirstPage();
6021
+ }
5580
6022
  }
5581
6023
  /**
5582
6024
  * The filter this column stands for. Defaults to the column's own `field`, because a store id
@@ -5625,8 +6067,7 @@ class SpiderlyDataTableComponent {
5625
6067
  this.columnMenu().hide();
5626
6068
  if (!col?.field || !this.isColumnSortable(col))
5627
6069
  return;
5628
- this.table._multiSortMeta = [{ field: col.field, order }];
5629
- this.table.sortMultiple();
6070
+ this.applySort([{ field: col.field, order }]);
5630
6071
  }
5631
6072
  /**
5632
6073
  * Sizes the column to the widest thing in it. The cheaper escape from a cramped column: no
@@ -5736,10 +6177,50 @@ class SpiderlyDataTableComponent {
5736
6177
  * left falls back to its own name rather than vanishing.
5737
6178
  */
5738
6179
  refreshSortKeys() {
5739
- this.sortKeys = (this.lastLazyLoadEvent?.multiSortMeta ?? []).map((key) => ({
5740
- label: this.cols.find((col) => col.field === key.field)?.name ?? key.field,
6180
+ const meta = this.lastLazyLoadEvent?.multiSortMeta ?? [];
6181
+ this.sortKeys = meta.map((key) => ({
6182
+ field: key.field,
6183
+ // A sort may name a field no column declares (keepSortableMeta allows it), and its chip
6184
+ // must still speak the operator's language: the same PascalCase-key convention the
6185
+ // scaffold seeds resolves it (Transloco falls back to the key, so worst case reads
6186
+ // "CreatedAt", never the raw camelCase identifier).
6187
+ label: this.cols.find((col) => col.field === key.field)?.name ??
6188
+ this.translocoService.translate(firstCharToUpper(key.field)),
5741
6189
  descending: key.order === -1,
5742
6190
  }));
6191
+ this.sortIsDefault = sortMetaEquals(meta, this.defaultMultiSortMeta() ?? []);
6192
+ }
6193
+ /**
6194
+ * The one body of an explicit sort GESTURE — the thing `persistActiveViewSort`'s doc calls
6195
+ * "explicit gestures only": page one, meta on the grid, fetch, save, selection anchor
6196
+ * invalidated, override recorded. Shared by the header's single-click un-sort, the header
6197
+ * menu and both bar entry points; `broadcastSort` is its deliberate no-fetch sibling. The
6198
+ * page reset, `saveState` and anchor line mirror PrimeNG's own `sort()` (`sortMultiple()`
6199
+ * does none of the three); the "lands on page one" / "survives a reload" specs pin why.
6200
+ */
6201
+ applySort(meta) {
6202
+ if (this.table.resetPageOnSort) {
6203
+ this.table._first = 0;
6204
+ this.table.firstChange.emit(0);
6205
+ }
6206
+ this.table._multiSortMeta = meta;
6207
+ this.table.sortMultiple();
6208
+ if (this.table.isStateful())
6209
+ this.table.saveState();
6210
+ this.table.anchorRowIndex = null;
6211
+ this.persistActiveViewSort();
6212
+ }
6213
+ /** The picker's ask: one sort, replacing the whole meta — the same contract as the header menu. */
6214
+ sortFromBar(pick) {
6215
+ this.applySort([pick]);
6216
+ }
6217
+ /**
6218
+ * Back to the resolved default — the view's declared sort, else the table default, else
6219
+ * unsorted. Persisting right after is what empties the override slot: a sort equal to the
6220
+ * default is stored as absence, so the declared ordering flows through again.
6221
+ */
6222
+ resetSortFromBar() {
6223
+ this.applySort(this.defaultMultiSortMeta() ?? []);
5743
6224
  }
5744
6225
  /** This column's share, override first, then its declared width, then the per-type default. */
5745
6226
  columnShare(col) {
@@ -6003,11 +6484,24 @@ class SpiderlyDataTableComponent {
6003
6484
  return;
6004
6485
  }
6005
6486
  this.persistAppliedFilters();
6006
- this.table.first = 0;
6007
- this.table.firstChange.emit(0);
6008
- this.lazyLoad(this.table.createLazyLoadMetadata());
6487
+ this.refetchFromFirstPage();
6009
6488
  }, { injector: this.injector });
6010
6489
  }
6490
+ /**
6491
+ * Back to page one, state saved, one fetch off FRESH metadata — the shared tail of the
6492
+ * requery effect and `selectView`'s sort-only switch. The save is load-bearing: PrimeNG
6493
+ * saves state only from its own interactions, so without it the pre-change page offset
6494
+ * survives in storage and the NEXT visit replays it against a narrowed result set — the
6495
+ * backend answers data=[] for an offset the filtered total no longer reaches, and the grid
6496
+ * renders empty under chips claiming matches.
6497
+ */
6498
+ refetchFromFirstPage() {
6499
+ this.table.first = 0;
6500
+ this.table.firstChange.emit(0);
6501
+ if (this.table.isStateful())
6502
+ this.table.saveState();
6503
+ this.lazyLoad(this.table.createLazyLoadMetadata());
6504
+ }
6011
6505
  reload() {
6012
6506
  // Nothing to replay before the table's own initial load, which is already on its way.
6013
6507
  if (this.lastLazyLoadEvent == null)
@@ -6108,14 +6602,17 @@ class SpiderlyDataTableComponent {
6108
6602
  exportListToExcel(this.exportListToExcelObservableMethod, filter);
6109
6603
  }
6110
6604
  /**
6111
- * Clears everything that narrows the grid. With a store supplied it has to reach BOTH — the
6112
- * store holds the constraints and PrimeNG holds the persisted state and sort — or the bar goes
6113
- * empty while a reload brings the old filters back.
6605
+ * Clears the FILTERS — and nothing else (2026-09-06, grilled). It used to also reach the
6606
+ * sort and PrimeNG's persisted state, which made "Clear filters" a reset the label never
6607
+ * claimed: sort is its own channel with its own affordances (the bar's picker, the header's
6608
+ * tri-state), mirroring Polaris, where onClearAll is wired to the Filters while
6609
+ * sortSelected never passes through it. The store's requery effect owns the refetch and the
6610
+ * persisted-snapshot update — the button only renders with chips on the bar, so the applied
6611
+ * set always changes — and dropping table.clear() also drops the second, unsequenced
6612
+ * request the 2026-09-05 review flagged on every Clear.
6114
6613
  */
6115
- clear(table) {
6614
+ clear() {
6116
6615
  this.filters?.clear();
6117
- table.clear();
6118
- table.clearState();
6119
6616
  }
6120
6617
  //#region Selection
6121
6618
  setFakeIsAllSelected() {
@@ -6289,7 +6786,7 @@ class SpiderlyDataTableComponent {
6289
6786
  }
6290
6787
  }
6291
6788
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyDataTableComponent, deps: [{ token: i3$2.Router }, { token: i1$5.DialogService }, { token: i3$2.ActivatedRoute }, { token: SpiderlyMessageService }, { token: i1.TranslocoService }, { token: ConfigServiceBase }, { token: i0.ErrorHandler }, { token: LOCALE_ID }], target: i0.ɵɵFactoryTarget.Component }); }
6292
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.2.13", type: SpiderlyDataTableComponent, isStandalone: true, selector: "spiderly-data-table", inputs: { tableTitle: "tableTitle", tableIcon: "tableIcon", items: "items", rows: "rows", rowsPerPageOptions: "rowsPerPageOptions", cols: "cols", filters: "filters", views: "views", showPaginator: "showPaginator", showCardWrapper: "showCardWrapper", readonly: "readonly", idField: "idField", getPaginatedListObservableMethod: "getPaginatedListObservableMethod", exportListToExcelObservableMethod: "exportListToExcelObservableMethod", deleteItemFromTableObservableMethod: "deleteItemFromTableObservableMethod", deleteListFromTableObservableMethod: "deleteListFromTableObservableMethod", newlySelectedItems: "newlySelectedItems", unselectedItems: "unselectedItems", selectionMode: "selectionMode", selectedLazyLoadObservableMethod: "selectedLazyLoadObservableMethod", additionalFilterIdLong: "additionalFilterIdLong", showAddButton: "showAddButton", showExportToExcelButton: "showExportToExcelButton", showReloadTableButton: "showReloadTableButton", getFormArrayItems: "getFormArrayItems", hasLazyLoad: "hasLazyLoad", stateKey: "stateKey", stateStorage: "stateStorage", defaultSortField: "defaultSortField", defaultSortOrder: "defaultSortOrder", getAlreadySelectedItemIds: "getAlreadySelectedItemIds", getAlreadySelectedItems: "getAlreadySelectedItems", getFormControl: "getFormControl", additionalIndexes: "additionalIndexes", navigateOnRowClick: "navigateOnRowClick", rowNavigationPath: "rowNavigationPath" }, outputs: { onTotalRecordsChange: "onTotalRecordsChange", onLazyLoad: "onLazyLoad", onIsAllSelectedChange: "onIsAllSelectedChange", onRowSelect: "onRowSelect", onRowUnselect: "onRowUnselect" }, queries: [{ propertyName: "actionsTemplate", first: true, predicate: SpiderlyDataTableActionsDirective, descendants: true, read: TemplateRef }, { propertyName: "cellTemplates", predicate: SpiderlyCellTemplateDirective }, { propertyName: "filterTemplates", predicate: SpiderlyFilterTemplateDirective }], viewQueries: [{ propertyName: "columnMenu", first: true, predicate: ["columnMenu"], descendants: true, isSignal: true }, { propertyName: "filterBar", first: true, predicate: SpiderlyFilterBarComponent, descendants: true, isSignal: true }, { propertyName: "selectionHeader", first: true, predicate: ["selectionHeader"], descendants: true, isSignal: true }, { propertyName: "table", first: true, predicate: ["dt"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<ng-container *transloco=\"let t\">\n <div\n #tableContainer\n class=\"spiderly-table-container\"\n [attr.aria-busy]=\"isPending\"\n [ngClass]=\"{\n 'card responsive-card-padding overflow-auto': showCardWrapper\n }\"\n >\n <p-table\n #dt\n [value]=\"items\"\n [rows]=\"rows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [rowHover]=\"true\"\n [paginator]=\"showPaginator\"\n responsiveLayout=\"scroll\"\n [lazy]=\"hasLazyLoad\"\n (onLazyLoad)=\"lazyLoad($event)\"\n (onPage)=\"onPageChange(tableContainer)\"\n [totalRecords]=\"totalRecords\"\n class=\"spiderly-table\"\n [loading]=\"isPending\"\n [selectionMode]=\"selectionMode\"\n dataKey=\"id\"\n sortMode=\"multiple\"\n [multiSortMeta]=\"initialMultiSortMeta\"\n [stateKey]=\"resolvedStateKey\"\n [stateStorage]=\"stateStorage\"\n [tableStyle]=\"tableStyle\"\n >\n <ng-template pTemplate=\"caption\">\n <div class=\"table-header overflow-auto\">\n <div style=\"display: flex; align-items: center; gap: 8px\">\n <i class=\"{{ tableIcon }}\" style=\"font-size: 20px\"></i>\n <div style=\"margin: 0px; font-size: 17.5px\">{{ tableTitle }}</div>\n </div>\n <div style=\"display: flex; gap: 8px\">\n <ng-container\n *ngIf=\"actionsTemplate\"\n [ngTemplateOutlet]=\"actionsTemplate\"\n ></ng-container>\n <button\n pButton\n *ngIf=\"hasLazyLoad\"\n data-testid=\"column-chooser-button\"\n [label]=\"t('Columns')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-sliders-h\"\n (click)=\"columnChooser.toggle($event)\"\n ></button>\n <button\n pButton\n *ngIf=\"showExportToExcelButton\"\n [label]=\"t('ExportToExcel')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-download\"\n (click)=\"exportListToExcel()\"\n ></button>\n <button\n pButton\n *ngIf=\"showReloadTableButton\"\n [label]=\"t('Reload')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-refresh\"\n (click)=\"reload()\"\n ></button>\n <button\n pButton\n *ngIf=\"deleteListFromTableObservableMethod && rowsSelectedNumber > 0\"\n [label]=\"t('DeleteSelected') + ' (' + rowsSelectedNumber + ')'\"\n class=\"p-button-danger\"\n style=\"flex: none\"\n icon=\"pi pi-trash\"\n (click)=\"deleteSelectedObjects()\"\n ></button>\n </div>\n </div>\n <!-- ONE menu for every column, with the target held in `menuColumn`. Rendering its items\n inside an @if keyed on the column would put them inside the popover's projected\n content, and PrimeNG does not re-project content an @if has destroyed \u2014 the filter\n bar's editor lost a whole afternoon to exactly that. -->\n <p-popover #columnMenu>\n <div class=\"column-menu\" role=\"menu\">\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-hide\"\n [disabled]=\"!menuColumn || !canToggleColumn(menuColumn)\"\n (click)=\"hideMenuColumn()\"\n >\n <i class=\"pi pi-eye-slash\"></i>\n <span class=\"column-menu-item-label\">{{ t('HideColumn') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-filter\"\n [disabled]=\"!menuColumn || !canFilterColumn(menuColumn)\"\n (click)=\"filterMenuColumn()\"\n >\n <i class=\"pi pi-filter\"></i>\n <span class=\"column-menu-item-label\">{{ t('Filter') }}\u2026</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-asc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(1)\"\n >\n <i class=\"pi pi-sort-amount-up-alt\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortAscending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-desc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(-1)\"\n >\n <i class=\"pi pi-sort-amount-down\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortDescending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-left\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, -1)\"\n (click)=\"moveMenuColumn(-1)\"\n >\n <i class=\"pi pi-arrow-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveLeft') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-right\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, 1)\"\n (click)=\"moveMenuColumn(1)\"\n >\n <i class=\"pi pi-arrow-right\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveRight') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-fit\"\n (click)=\"fitMenuColumn()\"\n >\n <i class=\"pi pi-arrows-h\"></i>\n <span class=\"column-menu-item-label\">{{ t('FitWidth') }}</span>\n </button>\n <!-- menuitemcheckbox, not menuitem: this one TOGGLES, and a label reading the same\n in both states leaves the operator to find out by clicking \u2014 which on a wide\n column means re-laying the grid out to learn what it was already set to. The\n check is the state; the label stays put, which is what the role is for.\n Disabled for an editable column: its cell holds an input, not clamped text, so\n the toggle could only persist a preference that changes nothing. -->\n <button\n type=\"button\"\n role=\"menuitemcheckbox\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-wrap\"\n [disabled]=\"!menuColumn || menuColumn.editable === true\"\n [attr.aria-checked]=\"isMenuColumnWrapped\"\n (click)=\"toggleWrapForMenuColumn()\"\n >\n <i class=\"pi pi-align-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('WrapText') }}</span>\n <i *ngIf=\"isMenuColumnWrapped\" class=\"pi pi-check\"></i>\n </button>\n </div>\n </p-popover>\n\n <div class=\"table-views\" *ngIf=\"views?.length\" role=\"tablist\">\n <button\n *ngFor=\"let view of views\"\n type=\"button\"\n role=\"tab\"\n class=\"table-view\"\n data-testid=\"table-view\"\n [class.table-view-active]=\"activeViewId === view.id\"\n [attr.aria-selected]=\"activeViewId === view.id\"\n (click)=\"selectView(view)\"\n >\n {{ view.label }}\n </button>\n </div>\n\n <spiderly-filter-bar\n *ngIf=\"filters\"\n [filters]=\"filters\"\n [totalRecords]=\"totalRecords\"\n [sort]=\"sortKeys\"\n [controlTemplates]=\"filterTemplates\"\n (clearAll)=\"clear(dt)\"\n ></spiderly-filter-bar>\n </ng-template>\n <ng-template pTemplate=\"header\">\n <tr>\n <!-- Sized for the checkbox plus the counter beside it, which shows `totalRecords` on a\n select-all and so reaches five digits on a large table. Not `0rem` (see CLAUDE.md \u2192\n Column widths), and not routed through getColWidth: this column has no `Column`. -->\n <th\n #selectionHeader\n class=\"frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.width]=\"selectionColumnWidth\"\n [style.left]=\"'0'\"\n *ngIf=\"selectionMode == 'multiple'\"\n >\n <div style=\"display: flex; gap: 8px\">\n <p-checkbox\n *ngIf=\"showSelectAllCheckbox\"\n [disabled]=\"readonly\"\n (onChange)=\"selectAll($event.checked)\"\n [(ngModel)]=\"fakeIsAllSelected\"\n [binary]=\"true\"\n ></p-checkbox>\n <span class=\"selection-count\">({{ rowsSelectedNumber }})</span>\n </div>\n </th>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <th\n #headerCell\n [pSortableColumn]=\"col.field\"\n [pSortableColumnDisabled]=\"!isColumnSortable(col)\"\n [style.width]=\"getColWidth(col)\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n [attr.draggable]=\"hasLazyLoad && !col.lockVisible ? true : null\"\n (mousedown)=\"onHeaderMouseDown($event)\"\n (dragstart)=\"onHeaderDragStart(col, $event)\"\n (dragover)=\"onHeaderDragOver(col, $event)\"\n (drop)=\"onHeaderDrop(col, $event)\"\n >\n <!-- Ours, not PrimeNG's: enabling [resizableColumns] makes it persist widths by\n th:nth-child INDEX into our own stateKey, which slides every stored width onto\n the wrong column the first time the chooser hides one (CLAUDE.md -> decision\n 5). We take the gesture and keep the state. -->\n <span\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n class=\"column-resizer\"\n data-testid=\"column-resizer\"\n (mousedown)=\"startColumnResize(col, $event, headerCell)\"\n ></span>\n <div\n style=\"\n display: flex;\n justify-content: space-between;\n align-items: center;\n \"\n >\n <span style=\"display: flex; align-items: center; gap: 4px\">\n {{ col.name }}\n <p-sortIcon\n *ngIf=\"isColumnSortable(col)\"\n [field]=\"col.field!\"\n ></p-sortIcon>\n <!-- stopPropagation is load-bearing: the th carries pSortableColumn, so a click\n that reaches it sorts the grid. Opening a menu must not reorder the table on\n the way. -->\n <button\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n type=\"button\"\n class=\"column-menu-button\"\n data-testid=\"column-menu\"\n [attr.aria-label]=\"t('Columns')\"\n (click)=\"$event.stopPropagation(); openColumnMenu(col, $event)\"\n >\n <i class=\"pi pi-chevron-down\"></i>\n </button>\n </span>\n </div>\n </th>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template\n pTemplate=\"body\"\n let-rowData\n let-index=\"rowIndex\"\n let-editing=\"editing\"\n >\n <!-- The cell rendering every column gets unless it projects a spiderlyCellTemplate. Declared\n once here rather than inside the column loop, and fed the same context the projected\n template gets, so getRowData is evaluated exactly once per cell either way. -->\n <ng-template #defaultCell let-col=\"col\" let-displayValue=\"displayValue\">\n <ng-container *ngIf=\"col.filterType === 'blob'\">\n <img width=\"45\" [src]=\"displayValue\" alt=\"\" />\n </ng-container>\n <ng-container *ngIf=\"col.filterType !== 'blob'\">\n <span spiderlyOverflowTitle class=\"cell-text\">{{\n displayValue\n }}</span>\n </ng-container>\n </ng-template>\n <tr\n [class.clickable]=\"navigateOnRowClick\"\n (click)=\"onRowClick(rowData, $event)\"\n >\n <!-- row-interactive: a selection gesture must never double as row navigation (onRowClick). -->\n <td\n *ngIf=\"selectionMode == 'multiple'\"\n class=\"selection-cell row-interactive frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.left]=\"'0'\"\n (mousedown)=\"onSelectionCellMouseDown($event, rowData[idField])\"\n >\n <p-checkbox\n [disabled]=\"readonly\"\n (onChange)=\"selectRow(rowData[idField], rowData.index)\"\n [ngModel]=\"isRowSelected(rowData[idField])\"\n [binary]=\"true\"\n ></p-checkbox>\n </td>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <td\n [style]=\"getStyleForBodyColumn(col)\"\n [class.clickable]=\"col.onCellClick\"\n [class.row-interactive]=\"col.onCellClick\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [class.cell-wrap]=\"isColumnWrapped(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n (click)=\"onCellClick(col, rowData, $event)\"\n *ngIf=\"!col.editable\"\n >\n <!-- getColWidth reserves this column's width from `actions.length`; this gap is\n the geometry it reserves for, so changing it belongs in the same edit. -->\n <div\n style=\"\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 18px;\n \"\n >\n <ng-container\n *ngFor=\"let action of col.actions; trackBy: actionTrackByFn\"\n >\n <span\n [pTooltip]=\"action.name\"\n [class]=\"getClassForAction(action) + ' row-interactive'\"\n [style]=\"getStyleForAction(action)\"\n (click)=\"getMethodForAction(action, rowData, $event)\"\n ></span>\n </ng-container>\n </div>\n <ng-container\n [ngTemplateOutlet]=\"getCellTemplate(col) ?? defaultCell\"\n [ngTemplateOutletContext]=\"{\n $implicit: rowData,\n value: rowData[col.field],\n displayValue: getRowData(rowData, col),\n col: col\n }\"\n ></ng-container>\n </td>\n <td\n *ngIf=\"col.editable\"\n class=\"row-interactive\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n >\n <spiderly-number\n [control]=\"getFormArrayControlByIndex(col.field, rowData.index)\"\n [showLabel]=\"false\"\n ></spiderly-number>\n </td>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"emptymessage\">\n <tr>\n <td\n [attr.colspan]=\"\n visibleCols.length + (selectionMode === 'multiple' ? 1 : 0)\n \"\n >\n {{ t(\"NoRecordsFound\") }}\n </td>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"paginatorleft\">\n {{ t(\"TotalRecords\") }}: {{ totalRecords }}\n </ng-template>\n <ng-template pTemplate=\"paginatorright\">\n <div style=\"display: flex; justify-content: end; gap: 10px\">\n <spiderly-button\n *ngIf=\"showAddButton\"\n [label]=\"t('AddNew')\"\n icon=\"pi pi-plus\"\n (onClick)=\"navigateToDetails(0)\"\n ></spiderly-button>\n </div>\n </ng-template>\n </p-table>\n <p-popover #columnChooser>\n <div class=\"column-chooser\">\n <label\n *ngFor=\"let col of chooserCols; trackBy: colTrackByFn\"\n data-testid=\"column-chooser-option\"\n class=\"column-chooser-option\"\n >\n <p-checkbox\n [binary]=\"true\"\n [ngModel]=\"isColumnVisible(col)\"\n [disabled]=\"!canToggleColumn(col)\"\n (onChange)=\"toggleColumn(col, $event.checked)\"\n ></p-checkbox>\n <span>{{ col.name }}</span>\n </label>\n <button\n type=\"button\"\n data-testid=\"column-chooser-reset\"\n class=\"column-chooser-reset\"\n (click)=\"resetColumnLayout()\"\n >\n {{ t('ResetToDefault') }}\n </button>\n </div>\n </p-popover>\n </div>\n</ng-container>\n", styles: [".table-header{display:flex;justify-content:space-between;align-items:center}@media (max-width: 640px){.table-header{display:flex;flex-direction:column;align-items:start;gap:14px}}.spiderly-table .p-paginator{padding:1rem}@media (min-width: 1400px){.spiderly-table .p-paginator-left-content{position:absolute;padding:14px;left:0}}@media (min-width: 1400px){.spiderly-table .p-paginator-right-content{position:absolute;padding:14px;right:0}}.column-chooser{display:flex;flex-direction:column;gap:8px}.column-chooser-option{display:flex;align-items:center;gap:8px;cursor:pointer}.column-chooser-reset{border:none;background:none;color:inherit;font:inherit;cursor:pointer;padding:0;margin-top:4px;color:var(--p-primary-color);align-self:flex-start;font-size:.875rem}.column-chooser-reset:hover{text-decoration:underline}.column-menu{display:flex;flex-direction:column;min-width:11rem}.column-menu-item{display:flex;align-items:center;gap:8px;border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.column-menu-item .column-menu-item-label{flex:1}.column-menu-item:hover:not(:disabled){background:var(--p-content-hover-background)}.column-menu-item:disabled{color:var(--p-text-muted-color);cursor:default}:host .spiderly-table-container{scroll-margin-top:calc(var(--spiderly-viewport-top-inset, 0px) + 1rem)}:host ::ng-deep .clickable{cursor:pointer}:host ::ng-deep td.clickable:hover{text-decoration:underline}:host .column-menu-button{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:0 2px;opacity:0;font-size:.75rem}:host th:hover .column-menu-button,:host .column-menu-button:focus-visible{opacity:1}:host .table-views{display:flex;flex-wrap:wrap;gap:4px;padding-top:8px}:host .table-view{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:4px 10px;border-radius:var(--p-content-border-radius)}:host .table-view:hover{background:var(--p-content-hover-background)}:host .table-view-active{color:var(--p-primary-color);font-weight:600;background:var(--p-content-hover-background)}:host .frozen-column{position:sticky;z-index:1}:host td.frozen-column{background:var(--p-datatable-row-background, var(--p-content-background));transition:background-color var(--p-transition-duration, .2s)}:host tr:hover>td.frozen-column{background:var(--p-datatable-row-hover-background, inherit)}:host .frozen-column-last{box-shadow:2px 0 4px -2px #00000026}:host th{position:relative}:host .column-resizer{position:absolute;top:0;right:0;width:8px;height:100%;cursor:col-resize;-webkit-user-select:none;user-select:none}:host .selection-count{font-size:.75rem;color:var(--p-text-muted-color)}:host td.selection-cell{-webkit-user-select:none;user-select:none}:host td.cell-wrap{--spiderly-cell-overflow: visible;--spiderly-cell-white-space: normal}:host .cell-text{display:block;overflow:var(--spiderly-cell-overflow, hidden);text-overflow:ellipsis;white-space:var(--spiderly-cell-white-space, nowrap)}:host ::ng-deep .p-datatable-mask{background:color-mix(in srgb,var(--p-content-background, #ffffff) 65%,transparent);color:var(--p-primary-color, #334155);align-items:flex-start;padding-top:6rem}:host ::ng-deep .p-datatable-thead{position:unset!important}:host ::ng-deep .p-datatable-header{border-radius:6px 6px 0 0}:host ::ng-deep .p-datatable{border-radius:var(--p-content-border-radius);border:1px solid var(--p-datatable-border-color)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: SpiderlyControlsModule }, { kind: "component", type: SpiderlyButtonComponent, selector: "spiderly-button", inputs: ["type"] }, { kind: "component", type: SpiderlyNumberComponent, selector: "spiderly-number", inputs: ["prefix", "showButtons", "decimal", "maxFractionDigits"] }, { kind: "ngmodule", type: TableModule }, { kind: "component", type: i10.Table, selector: "p-table", inputs: ["frozenColumns", "frozenValue", "style", "styleClass", "tableStyle", "tableStyleClass", "paginator", "pageLinks", "rowsPerPageOptions", "alwaysShowPaginator", "paginatorPosition", "paginatorStyleClass", "paginatorDropdownAppendTo", "paginatorDropdownScrollHeight", "currentPageReportTemplate", "showCurrentPageReport", "showJumpToPageDropdown", "showJumpToPageInput", "showFirstLastIcon", "showPageLinks", "defaultSortOrder", "sortMode", "resetPageOnSort", "selectionMode", "selectionPageOnly", "contextMenuSelection", "contextMenuSelectionMode", "dataKey", "metaKeySelection", "rowSelectable", "rowTrackBy", "lazy", "lazyLoadOnInit", "compareSelectionBy", "csvSeparator", "exportFilename", "filters", "globalFilterFields", "filterDelay", "filterLocale", "expandedRowKeys", "editingRowKeys", "rowExpandMode", "scrollable", "scrollDirection", "rowGroupMode", "scrollHeight", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "virtualScrollDelay", "frozenWidth", "responsive", "contextMenu", "resizableColumns", "columnResizeMode", "reorderableColumns", "loading", "loadingIcon", "showLoader", "rowHover", "customSort", "showInitialSortBadge", "autoLayout", "exportFunction", "exportHeader", "stateKey", "stateStorage", "editMode", "groupRowsBy", "size", "showGridlines", "stripedRows", "groupRowsByOrder", "responsiveLayout", "breakpoint", "paginatorLocale", "value", "columns", "first", "rows", "totalRecords", "sortField", "sortOrder", "multiSortMeta", "selection", "virtualRowHeight", "selectAll"], outputs: ["contextMenuSelectionChange", "selectAllChange", "selectionChange", "onRowSelect", "onRowUnselect", "onPage", "onSort", "onFilter", "onLazyLoad", "onRowExpand", "onRowCollapse", "onContextMenuSelect", "onColResize", "onColReorder", "onRowReorder", "onEditInit", "onEditComplete", "onEditCancel", "onHeaderCheckboxToggle", "sortFunction", "firstChange", "rowsChange", "onStateSave", "onStateRestore"] }, { kind: "directive", type: i1$1.PrimeTemplate, selector: "[pTemplate]", inputs: ["type", "pTemplate"] }, { kind: "directive", type: i10.SortableColumn, selector: "[pSortableColumn]", inputs: ["pSortableColumn", "pSortableColumnDisabled"] }, { kind: "component", type: i10.SortIcon, selector: "p-sortIcon", inputs: ["field"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i3$3.ButtonDirective, selector: "[pButton]", inputs: ["iconPos", "loadingIcon", "loading", "severity", "raised", "rounded", "text", "outlined", "size", "plain", "fluid", "label", "icon", "buttonProps"] }, { kind: "ngmodule", type: PopoverModule }, { kind: "component", type: i8.Popover, selector: "p-popover", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions"], outputs: ["onShow", "onHide"] }, { kind: "ngmodule", type: CheckboxModule }, { kind: "component", type: i4$2.Checkbox, selector: "p-checkbox, p-checkBox, p-check-box", inputs: ["value", "name", "disabled", "binary", "ariaLabelledBy", "ariaLabel", "tabindex", "inputId", "style", "inputStyle", "styleClass", "inputClass", "indeterminate", "size", "formControl", "checkboxIcon", "readonly", "required", "autofocus", "trueValue", "falseValue", "variant"], outputs: ["onChange", "onFocus", "onBlur"] }, { kind: "component", type: SpiderlyFilterBarComponent, selector: "spiderly-filter-bar", inputs: ["filters", "totalRecords", "sort", "controlTemplates"], outputs: ["clearAll"] }, { kind: "directive", type: SpiderlyOverflowTitleDirective, selector: "[spiderlyOverflowTitle]" }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i5.Tooltip, selector: "[pTooltip]", inputs: ["tooltipPosition", "tooltipEvent", "appendTo", "positionStyle", "tooltipStyleClass", "tooltipZIndex", "escape", "showDelay", "hideDelay", "life", "positionTop", "positionLeft", "autoHide", "fitContent", "hideOnEscape", "pTooltip", "tooltipDisabled", "tooltipOptions"] }] }); }
6789
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.2.13", type: SpiderlyDataTableComponent, isStandalone: true, selector: "spiderly-data-table", inputs: { tableTitle: "tableTitle", tableIcon: "tableIcon", items: "items", rows: "rows", rowsPerPageOptions: "rowsPerPageOptions", cols: "cols", filters: "filters", views: "views", showPaginator: "showPaginator", showCardWrapper: "showCardWrapper", readonly: "readonly", idField: "idField", getPaginatedListObservableMethod: "getPaginatedListObservableMethod", exportListToExcelObservableMethod: "exportListToExcelObservableMethod", deleteItemFromTableObservableMethod: "deleteItemFromTableObservableMethod", deleteListFromTableObservableMethod: "deleteListFromTableObservableMethod", newlySelectedItems: "newlySelectedItems", unselectedItems: "unselectedItems", selectionMode: "selectionMode", selectedLazyLoadObservableMethod: "selectedLazyLoadObservableMethod", additionalFilterIdLong: "additionalFilterIdLong", showAddButton: "showAddButton", showExportToExcelButton: "showExportToExcelButton", showReloadTableButton: "showReloadTableButton", getFormArrayItems: "getFormArrayItems", hasLazyLoad: "hasLazyLoad", stateKey: "stateKey", stateStorage: "stateStorage", defaultSortField: "defaultSortField", defaultSortOrder: "defaultSortOrder", getAlreadySelectedItemIds: "getAlreadySelectedItemIds", getAlreadySelectedItems: "getAlreadySelectedItems", getFormControl: "getFormControl", additionalIndexes: "additionalIndexes", navigateOnRowClick: "navigateOnRowClick", rowNavigationPath: "rowNavigationPath" }, outputs: { onTotalRecordsChange: "onTotalRecordsChange", onLazyLoad: "onLazyLoad", onIsAllSelectedChange: "onIsAllSelectedChange", onRowSelect: "onRowSelect", onRowUnselect: "onRowUnselect" }, queries: [{ propertyName: "actionsTemplate", first: true, predicate: SpiderlyDataTableActionsDirective, descendants: true, read: TemplateRef }, { propertyName: "cellTemplates", predicate: SpiderlyCellTemplateDirective }, { propertyName: "filterTemplates", predicate: SpiderlyFilterTemplateDirective }], viewQueries: [{ propertyName: "columnMenu", first: true, predicate: ["columnMenu"], descendants: true, isSignal: true }, { propertyName: "filterBar", first: true, predicate: SpiderlyFilterBarComponent, descendants: true, isSignal: true }, { propertyName: "selectionHeader", first: true, predicate: ["selectionHeader"], descendants: true, isSignal: true }, { propertyName: "table", first: true, predicate: ["dt"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<ng-container *transloco=\"let t\">\n <div\n #tableContainer\n class=\"spiderly-table-container\"\n [attr.aria-busy]=\"isPending\"\n [ngClass]=\"{\n 'card responsive-card-padding overflow-auto': showCardWrapper\n }\"\n >\n <p-table\n #dt\n [value]=\"items\"\n [rows]=\"rows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [rowHover]=\"true\"\n [paginator]=\"showPaginator\"\n responsiveLayout=\"scroll\"\n [lazy]=\"hasLazyLoad\"\n (onLazyLoad)=\"lazyLoad($event)\"\n (onPage)=\"onPageChange(tableContainer)\"\n [totalRecords]=\"totalRecords\"\n class=\"spiderly-table\"\n [loading]=\"isPending\"\n [selectionMode]=\"selectionMode\"\n dataKey=\"id\"\n sortMode=\"multiple\"\n [multiSortMeta]=\"initialMultiSortMeta\"\n [stateKey]=\"resolvedStateKey\"\n [stateStorage]=\"stateStorage\"\n [tableStyle]=\"tableStyle\"\n >\n <ng-template pTemplate=\"caption\">\n <div class=\"table-header overflow-auto\">\n <div style=\"display: flex; align-items: center; gap: 8px\">\n <i class=\"{{ tableIcon }}\" style=\"font-size: 20px\"></i>\n <div style=\"margin: 0px; font-size: 17.5px\">{{ tableTitle }}</div>\n </div>\n <div style=\"display: flex; gap: 8px\">\n <ng-container\n *ngIf=\"actionsTemplate\"\n [ngTemplateOutlet]=\"actionsTemplate\"\n ></ng-container>\n <button\n pButton\n *ngIf=\"hasLazyLoad\"\n data-testid=\"column-chooser-button\"\n [label]=\"t('Columns')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-sliders-h\"\n (click)=\"columnChooser.toggle($event)\"\n ></button>\n <button\n pButton\n *ngIf=\"showExportToExcelButton\"\n [label]=\"t('ExportToExcel')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-download\"\n (click)=\"exportListToExcel()\"\n ></button>\n <button\n pButton\n *ngIf=\"showReloadTableButton\"\n [label]=\"t('Reload')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-refresh\"\n (click)=\"reload()\"\n ></button>\n <button\n pButton\n *ngIf=\"deleteListFromTableObservableMethod && rowsSelectedNumber > 0\"\n [label]=\"t('DeleteSelected') + ' (' + rowsSelectedNumber + ')'\"\n class=\"p-button-danger\"\n style=\"flex: none\"\n icon=\"pi pi-trash\"\n (click)=\"deleteSelectedObjects()\"\n ></button>\n </div>\n </div>\n <!-- ONE menu for every column, with the target held in `menuColumn`. Rendering its items\n inside an @if keyed on the column would put them inside the popover's projected\n content, and PrimeNG does not re-project content an @if has destroyed \u2014 the filter\n bar's editor lost a whole afternoon to exactly that. -->\n <p-popover #columnMenu>\n <div class=\"column-menu\" role=\"menu\">\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-hide\"\n [disabled]=\"!menuColumn || !canToggleColumn(menuColumn)\"\n (click)=\"hideMenuColumn()\"\n >\n <i class=\"pi pi-eye-slash\"></i>\n <span class=\"column-menu-item-label\">{{ t('HideColumn') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-filter\"\n [disabled]=\"!menuColumn || !canFilterColumn(menuColumn)\"\n (click)=\"filterMenuColumn()\"\n >\n <i class=\"pi pi-filter\"></i>\n <span class=\"column-menu-item-label\">{{ t('Filter') }}\u2026</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-asc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(1)\"\n >\n <i class=\"pi pi-sort-amount-up-alt\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortAscending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-desc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(-1)\"\n >\n <i class=\"pi pi-sort-amount-down\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortDescending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-left\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, -1)\"\n (click)=\"moveMenuColumn(-1)\"\n >\n <i class=\"pi pi-arrow-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveLeft') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-right\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, 1)\"\n (click)=\"moveMenuColumn(1)\"\n >\n <i class=\"pi pi-arrow-right\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveRight') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-fit\"\n (click)=\"fitMenuColumn()\"\n >\n <i class=\"pi pi-arrows-h\"></i>\n <span class=\"column-menu-item-label\">{{ t('FitWidth') }}</span>\n </button>\n <!-- menuitemcheckbox, not menuitem: this one TOGGLES, and a label reading the same\n in both states leaves the operator to find out by clicking \u2014 which on a wide\n column means re-laying the grid out to learn what it was already set to. The\n check is the state; the label stays put, which is what the role is for.\n Disabled for an editable column: its cell holds an input, not clamped text, so\n the toggle could only persist a preference that changes nothing. -->\n <button\n type=\"button\"\n role=\"menuitemcheckbox\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-wrap\"\n [disabled]=\"!menuColumn || menuColumn.editable === true\"\n [attr.aria-checked]=\"isMenuColumnWrapped\"\n (click)=\"toggleWrapForMenuColumn()\"\n >\n <i class=\"pi pi-align-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('WrapText') }}</span>\n <i *ngIf=\"isMenuColumnWrapped\" class=\"pi pi-check\"></i>\n </button>\n </div>\n </p-popover>\n\n <div class=\"table-views\" *ngIf=\"views?.length\" role=\"tablist\">\n <button\n *ngFor=\"let view of views\"\n type=\"button\"\n role=\"tab\"\n class=\"table-view\"\n data-testid=\"table-view\"\n [class.table-view-active]=\"activeViewId === view.id\"\n [attr.aria-selected]=\"activeViewId === view.id\"\n (click)=\"selectView(view)\"\n >\n {{ view.label }}\n </button>\n </div>\n\n <spiderly-filter-bar\n *ngIf=\"filters\"\n [filters]=\"filters\"\n [totalRecords]=\"totalRecords\"\n [sort]=\"sortKeys\"\n [sortOptions]=\"sortPickOptions\"\n [sortIsDefault]=\"sortIsDefault\"\n [controlTemplates]=\"filterTemplates\"\n (clearAll)=\"clear()\"\n (sortPick)=\"sortFromBar($event)\"\n (sortReset)=\"resetSortFromBar()\"\n ></spiderly-filter-bar>\n </ng-template>\n <ng-template pTemplate=\"header\">\n <tr>\n <!-- Sized for the checkbox plus the counter beside it, which shows `totalRecords` on a\n select-all and so reaches five digits on a large table. Not `0rem` (see CLAUDE.md \u2192\n Column widths), and not routed through getColWidth: this column has no `Column`. -->\n <th\n #selectionHeader\n class=\"frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.width]=\"selectionColumnWidth\"\n [style.left]=\"'0'\"\n *ngIf=\"selectionMode == 'multiple'\"\n >\n <div style=\"display: flex; gap: 8px\">\n <p-checkbox\n *ngIf=\"showSelectAllCheckbox\"\n [disabled]=\"readonly\"\n (onChange)=\"selectAll($event.checked)\"\n [(ngModel)]=\"fakeIsAllSelected\"\n [binary]=\"true\"\n ></p-checkbox>\n <span class=\"selection-count\">({{ rowsSelectedNumber }})</span>\n </div>\n </th>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <th\n #headerCell\n [pSortableColumn]=\"col.field\"\n [pSortableColumnDisabled]=\"!isColumnSortable(col)\"\n [style.width]=\"getColWidth(col)\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n [attr.draggable]=\"hasLazyLoad && !col.lockVisible ? true : null\"\n (mousedown)=\"onHeaderMouseDown($event)\"\n (dragstart)=\"onHeaderDragStart(col, $event)\"\n (dragover)=\"onHeaderDragOver(col, $event)\"\n (drop)=\"onHeaderDrop(col, $event)\"\n >\n <!-- Ours, not PrimeNG's: enabling [resizableColumns] makes it persist widths by\n th:nth-child INDEX into our own stateKey, which slides every stored width onto\n the wrong column the first time the chooser hides one (CLAUDE.md -> decision\n 5). We take the gesture and keep the state. -->\n <span\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n class=\"column-resizer\"\n data-testid=\"column-resizer\"\n (mousedown)=\"startColumnResize(col, $event, headerCell)\"\n ></span>\n <div\n style=\"\n display: flex;\n justify-content: space-between;\n align-items: center;\n \"\n >\n <span style=\"display: flex; align-items: center; gap: 4px\">\n {{ col.name }}\n <p-sortIcon\n *ngIf=\"isColumnSortable(col)\"\n [field]=\"col.field!\"\n ></p-sortIcon>\n <!-- stopPropagation is load-bearing: the th carries pSortableColumn, so a click\n that reaches it sorts the grid. Opening a menu must not reorder the table on\n the way. -->\n <button\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n type=\"button\"\n class=\"column-menu-button\"\n data-testid=\"column-menu\"\n [attr.aria-label]=\"t('Columns')\"\n (click)=\"$event.stopPropagation(); openColumnMenu(col, $event)\"\n >\n <i class=\"pi pi-chevron-down\"></i>\n </button>\n </span>\n </div>\n </th>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template\n pTemplate=\"body\"\n let-rowData\n let-index=\"rowIndex\"\n let-editing=\"editing\"\n >\n <!-- The cell rendering every column gets unless it projects a spiderlyCellTemplate. Declared\n once here rather than inside the column loop, and fed the same context the projected\n template gets, so getRowData is evaluated exactly once per cell either way. -->\n <ng-template #defaultCell let-col=\"col\" let-displayValue=\"displayValue\">\n <ng-container *ngIf=\"col.filterType === 'blob'\">\n <img width=\"45\" [src]=\"displayValue\" alt=\"\" />\n </ng-container>\n <ng-container *ngIf=\"col.filterType !== 'blob'\">\n <span spiderlyOverflowTitle class=\"cell-text\">{{\n displayValue\n }}</span>\n </ng-container>\n </ng-template>\n <tr\n [class.clickable]=\"navigateOnRowClick\"\n (click)=\"onRowClick(rowData, $event)\"\n >\n <!-- row-interactive: a selection gesture must never double as row navigation (onRowClick). -->\n <td\n *ngIf=\"selectionMode == 'multiple'\"\n class=\"selection-cell row-interactive frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.left]=\"'0'\"\n (mousedown)=\"onSelectionCellMouseDown($event, rowData[idField])\"\n >\n <p-checkbox\n [disabled]=\"readonly\"\n (onChange)=\"selectRow(rowData[idField], rowData.index)\"\n [ngModel]=\"isRowSelected(rowData[idField])\"\n [binary]=\"true\"\n ></p-checkbox>\n </td>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <td\n [style]=\"getStyleForBodyColumn(col)\"\n [class.clickable]=\"col.onCellClick\"\n [class.row-interactive]=\"col.onCellClick\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [class.cell-wrap]=\"isColumnWrapped(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n (click)=\"onCellClick(col, rowData, $event)\"\n *ngIf=\"!col.editable\"\n >\n <!-- getColWidth reserves this column's width from `actions.length`; this gap is\n the geometry it reserves for, so changing it belongs in the same edit. -->\n <div\n style=\"\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 18px;\n \"\n >\n <ng-container\n *ngFor=\"let action of col.actions; trackBy: actionTrackByFn\"\n >\n <span\n [pTooltip]=\"action.name\"\n [class]=\"getClassForAction(action) + ' row-interactive'\"\n [style]=\"getStyleForAction(action)\"\n (click)=\"getMethodForAction(action, rowData, $event)\"\n ></span>\n </ng-container>\n </div>\n <ng-container\n [ngTemplateOutlet]=\"getCellTemplate(col) ?? defaultCell\"\n [ngTemplateOutletContext]=\"{\n $implicit: rowData,\n value: rowData[col.field],\n displayValue: getRowData(rowData, col),\n col: col\n }\"\n ></ng-container>\n </td>\n <td\n *ngIf=\"col.editable\"\n class=\"row-interactive\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n >\n <spiderly-number\n [control]=\"getFormArrayControlByIndex(col.field, rowData.index)\"\n [showLabel]=\"false\"\n ></spiderly-number>\n </td>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"emptymessage\">\n <tr>\n <td\n [attr.colspan]=\"\n visibleCols.length + (selectionMode === 'multiple' ? 1 : 0)\n \"\n >\n {{ t(\"NoRecordsFound\") }}\n </td>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"paginatorleft\">\n {{ t(\"TotalRecords\") }}: {{ totalRecords }}\n </ng-template>\n <ng-template pTemplate=\"paginatorright\">\n <div style=\"display: flex; justify-content: end; gap: 10px\">\n <spiderly-button\n *ngIf=\"showAddButton\"\n [label]=\"t('AddNew')\"\n icon=\"pi pi-plus\"\n (onClick)=\"navigateToDetails(0)\"\n ></spiderly-button>\n </div>\n </ng-template>\n </p-table>\n <p-popover #columnChooser>\n <div class=\"column-chooser\">\n <label\n *ngFor=\"let col of chooserCols; trackBy: colTrackByFn\"\n data-testid=\"column-chooser-option\"\n class=\"column-chooser-option\"\n >\n <p-checkbox\n [binary]=\"true\"\n [ngModel]=\"isColumnVisible(col)\"\n [disabled]=\"!canToggleColumn(col)\"\n (onChange)=\"toggleColumn(col, $event.checked)\"\n ></p-checkbox>\n <span>{{ col.name }}</span>\n </label>\n <button\n type=\"button\"\n data-testid=\"column-chooser-reset\"\n class=\"column-chooser-reset\"\n (click)=\"resetColumnLayout()\"\n >\n {{ t('ResetToDefault') }}\n </button>\n </div>\n </p-popover>\n </div>\n</ng-container>\n", styles: [".table-header{display:flex;justify-content:space-between;align-items:center}@media (max-width: 640px){.table-header{display:flex;flex-direction:column;align-items:start;gap:14px}}.spiderly-table .p-paginator{padding:1rem}@media (min-width: 1400px){.spiderly-table .p-paginator-left-content{position:absolute;padding:14px;left:0}}@media (min-width: 1400px){.spiderly-table .p-paginator-right-content{position:absolute;padding:14px;right:0}}.column-chooser{display:flex;flex-direction:column;gap:8px}.column-chooser-option{display:flex;align-items:center;gap:8px;cursor:pointer}.column-chooser-reset{border:none;background:none;color:inherit;font:inherit;cursor:pointer;padding:0;margin-top:4px;color:var(--p-primary-color);align-self:flex-start;font-size:.875rem}.column-chooser-reset:hover{text-decoration:underline}.column-menu{display:flex;flex-direction:column;min-width:11rem}.column-menu-item{display:flex;align-items:center;gap:8px;border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.column-menu-item .column-menu-item-label{flex:1}.column-menu-item:hover:not(:disabled){background:var(--p-content-hover-background)}.column-menu-item:disabled{color:var(--p-text-muted-color);cursor:default}:host .spiderly-table-container{scroll-margin-top:calc(var(--spiderly-viewport-top-inset, 0px) + 1rem)}:host ::ng-deep .clickable{cursor:pointer}:host ::ng-deep td.clickable:hover{text-decoration:underline}:host .column-menu-button{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:0 2px;opacity:0;font-size:.75rem}:host th:hover .column-menu-button,:host .column-menu-button:focus-visible{opacity:1}:host .table-views{display:flex;flex-wrap:wrap;gap:4px;padding-top:8px}:host .table-view{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:4px 10px;border-radius:var(--p-content-border-radius)}:host .table-view:hover{background:var(--p-content-hover-background)}:host .table-view-active{color:var(--p-primary-color);font-weight:600;background:var(--p-content-hover-background)}:host .frozen-column{position:sticky;z-index:1}:host td.frozen-column{background:var(--p-datatable-row-background, var(--p-content-background));transition:background-color var(--p-transition-duration, .2s)}:host tr:hover>td.frozen-column{background:var(--p-datatable-row-hover-background, inherit)}:host .frozen-column-last{box-shadow:2px 0 4px -2px #00000026}:host th{position:relative}:host .column-resizer{position:absolute;top:0;right:0;width:8px;height:100%;cursor:col-resize;-webkit-user-select:none;user-select:none}:host .selection-count{font-size:.75rem;color:var(--p-text-muted-color)}:host td.selection-cell{-webkit-user-select:none;user-select:none}:host td.cell-wrap{--spiderly-cell-overflow: visible;--spiderly-cell-white-space: normal}:host .cell-text{display:block;overflow:var(--spiderly-cell-overflow, hidden);text-overflow:ellipsis;white-space:var(--spiderly-cell-white-space, nowrap)}:host ::ng-deep .p-datatable-mask{background:color-mix(in srgb,var(--p-content-background, #ffffff) 65%,transparent);color:var(--p-primary-color, #334155);align-items:flex-start;padding-top:6rem}:host ::ng-deep .p-datatable-thead{position:unset!important}:host ::ng-deep .p-datatable-header{border-radius:6px 6px 0 0}:host ::ng-deep .p-datatable{border-radius:var(--p-content-border-radius);border:1px solid var(--p-datatable-border-color)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: SpiderlyControlsModule }, { kind: "component", type: SpiderlyButtonComponent, selector: "spiderly-button", inputs: ["type"] }, { kind: "component", type: SpiderlyNumberComponent, selector: "spiderly-number", inputs: ["prefix", "showButtons", "decimal", "maxFractionDigits"] }, { kind: "ngmodule", type: TableModule }, { kind: "component", type: i10.Table, selector: "p-table", inputs: ["frozenColumns", "frozenValue", "style", "styleClass", "tableStyle", "tableStyleClass", "paginator", "pageLinks", "rowsPerPageOptions", "alwaysShowPaginator", "paginatorPosition", "paginatorStyleClass", "paginatorDropdownAppendTo", "paginatorDropdownScrollHeight", "currentPageReportTemplate", "showCurrentPageReport", "showJumpToPageDropdown", "showJumpToPageInput", "showFirstLastIcon", "showPageLinks", "defaultSortOrder", "sortMode", "resetPageOnSort", "selectionMode", "selectionPageOnly", "contextMenuSelection", "contextMenuSelectionMode", "dataKey", "metaKeySelection", "rowSelectable", "rowTrackBy", "lazy", "lazyLoadOnInit", "compareSelectionBy", "csvSeparator", "exportFilename", "filters", "globalFilterFields", "filterDelay", "filterLocale", "expandedRowKeys", "editingRowKeys", "rowExpandMode", "scrollable", "scrollDirection", "rowGroupMode", "scrollHeight", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "virtualScrollDelay", "frozenWidth", "responsive", "contextMenu", "resizableColumns", "columnResizeMode", "reorderableColumns", "loading", "loadingIcon", "showLoader", "rowHover", "customSort", "showInitialSortBadge", "autoLayout", "exportFunction", "exportHeader", "stateKey", "stateStorage", "editMode", "groupRowsBy", "size", "showGridlines", "stripedRows", "groupRowsByOrder", "responsiveLayout", "breakpoint", "paginatorLocale", "value", "columns", "first", "rows", "totalRecords", "sortField", "sortOrder", "multiSortMeta", "selection", "virtualRowHeight", "selectAll"], outputs: ["contextMenuSelectionChange", "selectAllChange", "selectionChange", "onRowSelect", "onRowUnselect", "onPage", "onSort", "onFilter", "onLazyLoad", "onRowExpand", "onRowCollapse", "onContextMenuSelect", "onColResize", "onColReorder", "onRowReorder", "onEditInit", "onEditComplete", "onEditCancel", "onHeaderCheckboxToggle", "sortFunction", "firstChange", "rowsChange", "onStateSave", "onStateRestore"] }, { kind: "directive", type: i1$1.PrimeTemplate, selector: "[pTemplate]", inputs: ["type", "pTemplate"] }, { kind: "directive", type: i10.SortableColumn, selector: "[pSortableColumn]", inputs: ["pSortableColumn", "pSortableColumnDisabled"] }, { kind: "component", type: i10.SortIcon, selector: "p-sortIcon", inputs: ["field"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i3$3.ButtonDirective, selector: "[pButton]", inputs: ["iconPos", "loadingIcon", "loading", "severity", "raised", "rounded", "text", "outlined", "size", "plain", "fluid", "label", "icon", "buttonProps"] }, { kind: "ngmodule", type: PopoverModule }, { kind: "component", type: i8.Popover, selector: "p-popover", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions"], outputs: ["onShow", "onHide"] }, { kind: "ngmodule", type: CheckboxModule }, { kind: "component", type: i4$2.Checkbox, selector: "p-checkbox, p-checkBox, p-check-box", inputs: ["value", "name", "disabled", "binary", "ariaLabelledBy", "ariaLabel", "tabindex", "inputId", "style", "inputStyle", "styleClass", "inputClass", "indeterminate", "size", "formControl", "checkboxIcon", "readonly", "required", "autofocus", "trueValue", "falseValue", "variant"], outputs: ["onChange", "onFocus", "onBlur"] }, { kind: "component", type: SpiderlyFilterBarComponent, selector: "spiderly-filter-bar", inputs: ["filters", "totalRecords", "sort", "sortOptions", "sortIsDefault", "controlTemplates"], outputs: ["clearAll", "sortPick", "sortReset"] }, { kind: "directive", type: SpiderlyOverflowTitleDirective, selector: "[spiderlyOverflowTitle]" }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i5.Tooltip, selector: "[pTooltip]", inputs: ["tooltipPosition", "tooltipEvent", "appendTo", "positionStyle", "tooltipStyleClass", "tooltipZIndex", "escape", "showDelay", "hideDelay", "life", "positionTop", "positionLeft", "autoHide", "fitContent", "hideOnEscape", "pTooltip", "tooltipDisabled", "tooltipOptions"] }] }); }
6293
6790
  }
6294
6791
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImport: i0, type: SpiderlyDataTableComponent, decorators: [{
6295
6792
  type: Component,
@@ -6305,7 +6802,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
6305
6802
  SpiderlyFilterBarComponent,
6306
6803
  SpiderlyOverflowTitleDirective,
6307
6804
  TooltipModule,
6308
- ], template: "<ng-container *transloco=\"let t\">\n <div\n #tableContainer\n class=\"spiderly-table-container\"\n [attr.aria-busy]=\"isPending\"\n [ngClass]=\"{\n 'card responsive-card-padding overflow-auto': showCardWrapper\n }\"\n >\n <p-table\n #dt\n [value]=\"items\"\n [rows]=\"rows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [rowHover]=\"true\"\n [paginator]=\"showPaginator\"\n responsiveLayout=\"scroll\"\n [lazy]=\"hasLazyLoad\"\n (onLazyLoad)=\"lazyLoad($event)\"\n (onPage)=\"onPageChange(tableContainer)\"\n [totalRecords]=\"totalRecords\"\n class=\"spiderly-table\"\n [loading]=\"isPending\"\n [selectionMode]=\"selectionMode\"\n dataKey=\"id\"\n sortMode=\"multiple\"\n [multiSortMeta]=\"initialMultiSortMeta\"\n [stateKey]=\"resolvedStateKey\"\n [stateStorage]=\"stateStorage\"\n [tableStyle]=\"tableStyle\"\n >\n <ng-template pTemplate=\"caption\">\n <div class=\"table-header overflow-auto\">\n <div style=\"display: flex; align-items: center; gap: 8px\">\n <i class=\"{{ tableIcon }}\" style=\"font-size: 20px\"></i>\n <div style=\"margin: 0px; font-size: 17.5px\">{{ tableTitle }}</div>\n </div>\n <div style=\"display: flex; gap: 8px\">\n <ng-container\n *ngIf=\"actionsTemplate\"\n [ngTemplateOutlet]=\"actionsTemplate\"\n ></ng-container>\n <button\n pButton\n *ngIf=\"hasLazyLoad\"\n data-testid=\"column-chooser-button\"\n [label]=\"t('Columns')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-sliders-h\"\n (click)=\"columnChooser.toggle($event)\"\n ></button>\n <button\n pButton\n *ngIf=\"showExportToExcelButton\"\n [label]=\"t('ExportToExcel')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-download\"\n (click)=\"exportListToExcel()\"\n ></button>\n <button\n pButton\n *ngIf=\"showReloadTableButton\"\n [label]=\"t('Reload')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-refresh\"\n (click)=\"reload()\"\n ></button>\n <button\n pButton\n *ngIf=\"deleteListFromTableObservableMethod && rowsSelectedNumber > 0\"\n [label]=\"t('DeleteSelected') + ' (' + rowsSelectedNumber + ')'\"\n class=\"p-button-danger\"\n style=\"flex: none\"\n icon=\"pi pi-trash\"\n (click)=\"deleteSelectedObjects()\"\n ></button>\n </div>\n </div>\n <!-- ONE menu for every column, with the target held in `menuColumn`. Rendering its items\n inside an @if keyed on the column would put them inside the popover's projected\n content, and PrimeNG does not re-project content an @if has destroyed \u2014 the filter\n bar's editor lost a whole afternoon to exactly that. -->\n <p-popover #columnMenu>\n <div class=\"column-menu\" role=\"menu\">\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-hide\"\n [disabled]=\"!menuColumn || !canToggleColumn(menuColumn)\"\n (click)=\"hideMenuColumn()\"\n >\n <i class=\"pi pi-eye-slash\"></i>\n <span class=\"column-menu-item-label\">{{ t('HideColumn') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-filter\"\n [disabled]=\"!menuColumn || !canFilterColumn(menuColumn)\"\n (click)=\"filterMenuColumn()\"\n >\n <i class=\"pi pi-filter\"></i>\n <span class=\"column-menu-item-label\">{{ t('Filter') }}\u2026</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-asc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(1)\"\n >\n <i class=\"pi pi-sort-amount-up-alt\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortAscending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-desc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(-1)\"\n >\n <i class=\"pi pi-sort-amount-down\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortDescending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-left\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, -1)\"\n (click)=\"moveMenuColumn(-1)\"\n >\n <i class=\"pi pi-arrow-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveLeft') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-right\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, 1)\"\n (click)=\"moveMenuColumn(1)\"\n >\n <i class=\"pi pi-arrow-right\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveRight') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-fit\"\n (click)=\"fitMenuColumn()\"\n >\n <i class=\"pi pi-arrows-h\"></i>\n <span class=\"column-menu-item-label\">{{ t('FitWidth') }}</span>\n </button>\n <!-- menuitemcheckbox, not menuitem: this one TOGGLES, and a label reading the same\n in both states leaves the operator to find out by clicking \u2014 which on a wide\n column means re-laying the grid out to learn what it was already set to. The\n check is the state; the label stays put, which is what the role is for.\n Disabled for an editable column: its cell holds an input, not clamped text, so\n the toggle could only persist a preference that changes nothing. -->\n <button\n type=\"button\"\n role=\"menuitemcheckbox\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-wrap\"\n [disabled]=\"!menuColumn || menuColumn.editable === true\"\n [attr.aria-checked]=\"isMenuColumnWrapped\"\n (click)=\"toggleWrapForMenuColumn()\"\n >\n <i class=\"pi pi-align-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('WrapText') }}</span>\n <i *ngIf=\"isMenuColumnWrapped\" class=\"pi pi-check\"></i>\n </button>\n </div>\n </p-popover>\n\n <div class=\"table-views\" *ngIf=\"views?.length\" role=\"tablist\">\n <button\n *ngFor=\"let view of views\"\n type=\"button\"\n role=\"tab\"\n class=\"table-view\"\n data-testid=\"table-view\"\n [class.table-view-active]=\"activeViewId === view.id\"\n [attr.aria-selected]=\"activeViewId === view.id\"\n (click)=\"selectView(view)\"\n >\n {{ view.label }}\n </button>\n </div>\n\n <spiderly-filter-bar\n *ngIf=\"filters\"\n [filters]=\"filters\"\n [totalRecords]=\"totalRecords\"\n [sort]=\"sortKeys\"\n [controlTemplates]=\"filterTemplates\"\n (clearAll)=\"clear(dt)\"\n ></spiderly-filter-bar>\n </ng-template>\n <ng-template pTemplate=\"header\">\n <tr>\n <!-- Sized for the checkbox plus the counter beside it, which shows `totalRecords` on a\n select-all and so reaches five digits on a large table. Not `0rem` (see CLAUDE.md \u2192\n Column widths), and not routed through getColWidth: this column has no `Column`. -->\n <th\n #selectionHeader\n class=\"frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.width]=\"selectionColumnWidth\"\n [style.left]=\"'0'\"\n *ngIf=\"selectionMode == 'multiple'\"\n >\n <div style=\"display: flex; gap: 8px\">\n <p-checkbox\n *ngIf=\"showSelectAllCheckbox\"\n [disabled]=\"readonly\"\n (onChange)=\"selectAll($event.checked)\"\n [(ngModel)]=\"fakeIsAllSelected\"\n [binary]=\"true\"\n ></p-checkbox>\n <span class=\"selection-count\">({{ rowsSelectedNumber }})</span>\n </div>\n </th>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <th\n #headerCell\n [pSortableColumn]=\"col.field\"\n [pSortableColumnDisabled]=\"!isColumnSortable(col)\"\n [style.width]=\"getColWidth(col)\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n [attr.draggable]=\"hasLazyLoad && !col.lockVisible ? true : null\"\n (mousedown)=\"onHeaderMouseDown($event)\"\n (dragstart)=\"onHeaderDragStart(col, $event)\"\n (dragover)=\"onHeaderDragOver(col, $event)\"\n (drop)=\"onHeaderDrop(col, $event)\"\n >\n <!-- Ours, not PrimeNG's: enabling [resizableColumns] makes it persist widths by\n th:nth-child INDEX into our own stateKey, which slides every stored width onto\n the wrong column the first time the chooser hides one (CLAUDE.md -> decision\n 5). We take the gesture and keep the state. -->\n <span\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n class=\"column-resizer\"\n data-testid=\"column-resizer\"\n (mousedown)=\"startColumnResize(col, $event, headerCell)\"\n ></span>\n <div\n style=\"\n display: flex;\n justify-content: space-between;\n align-items: center;\n \"\n >\n <span style=\"display: flex; align-items: center; gap: 4px\">\n {{ col.name }}\n <p-sortIcon\n *ngIf=\"isColumnSortable(col)\"\n [field]=\"col.field!\"\n ></p-sortIcon>\n <!-- stopPropagation is load-bearing: the th carries pSortableColumn, so a click\n that reaches it sorts the grid. Opening a menu must not reorder the table on\n the way. -->\n <button\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n type=\"button\"\n class=\"column-menu-button\"\n data-testid=\"column-menu\"\n [attr.aria-label]=\"t('Columns')\"\n (click)=\"$event.stopPropagation(); openColumnMenu(col, $event)\"\n >\n <i class=\"pi pi-chevron-down\"></i>\n </button>\n </span>\n </div>\n </th>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template\n pTemplate=\"body\"\n let-rowData\n let-index=\"rowIndex\"\n let-editing=\"editing\"\n >\n <!-- The cell rendering every column gets unless it projects a spiderlyCellTemplate. Declared\n once here rather than inside the column loop, and fed the same context the projected\n template gets, so getRowData is evaluated exactly once per cell either way. -->\n <ng-template #defaultCell let-col=\"col\" let-displayValue=\"displayValue\">\n <ng-container *ngIf=\"col.filterType === 'blob'\">\n <img width=\"45\" [src]=\"displayValue\" alt=\"\" />\n </ng-container>\n <ng-container *ngIf=\"col.filterType !== 'blob'\">\n <span spiderlyOverflowTitle class=\"cell-text\">{{\n displayValue\n }}</span>\n </ng-container>\n </ng-template>\n <tr\n [class.clickable]=\"navigateOnRowClick\"\n (click)=\"onRowClick(rowData, $event)\"\n >\n <!-- row-interactive: a selection gesture must never double as row navigation (onRowClick). -->\n <td\n *ngIf=\"selectionMode == 'multiple'\"\n class=\"selection-cell row-interactive frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.left]=\"'0'\"\n (mousedown)=\"onSelectionCellMouseDown($event, rowData[idField])\"\n >\n <p-checkbox\n [disabled]=\"readonly\"\n (onChange)=\"selectRow(rowData[idField], rowData.index)\"\n [ngModel]=\"isRowSelected(rowData[idField])\"\n [binary]=\"true\"\n ></p-checkbox>\n </td>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <td\n [style]=\"getStyleForBodyColumn(col)\"\n [class.clickable]=\"col.onCellClick\"\n [class.row-interactive]=\"col.onCellClick\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [class.cell-wrap]=\"isColumnWrapped(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n (click)=\"onCellClick(col, rowData, $event)\"\n *ngIf=\"!col.editable\"\n >\n <!-- getColWidth reserves this column's width from `actions.length`; this gap is\n the geometry it reserves for, so changing it belongs in the same edit. -->\n <div\n style=\"\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 18px;\n \"\n >\n <ng-container\n *ngFor=\"let action of col.actions; trackBy: actionTrackByFn\"\n >\n <span\n [pTooltip]=\"action.name\"\n [class]=\"getClassForAction(action) + ' row-interactive'\"\n [style]=\"getStyleForAction(action)\"\n (click)=\"getMethodForAction(action, rowData, $event)\"\n ></span>\n </ng-container>\n </div>\n <ng-container\n [ngTemplateOutlet]=\"getCellTemplate(col) ?? defaultCell\"\n [ngTemplateOutletContext]=\"{\n $implicit: rowData,\n value: rowData[col.field],\n displayValue: getRowData(rowData, col),\n col: col\n }\"\n ></ng-container>\n </td>\n <td\n *ngIf=\"col.editable\"\n class=\"row-interactive\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n >\n <spiderly-number\n [control]=\"getFormArrayControlByIndex(col.field, rowData.index)\"\n [showLabel]=\"false\"\n ></spiderly-number>\n </td>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"emptymessage\">\n <tr>\n <td\n [attr.colspan]=\"\n visibleCols.length + (selectionMode === 'multiple' ? 1 : 0)\n \"\n >\n {{ t(\"NoRecordsFound\") }}\n </td>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"paginatorleft\">\n {{ t(\"TotalRecords\") }}: {{ totalRecords }}\n </ng-template>\n <ng-template pTemplate=\"paginatorright\">\n <div style=\"display: flex; justify-content: end; gap: 10px\">\n <spiderly-button\n *ngIf=\"showAddButton\"\n [label]=\"t('AddNew')\"\n icon=\"pi pi-plus\"\n (onClick)=\"navigateToDetails(0)\"\n ></spiderly-button>\n </div>\n </ng-template>\n </p-table>\n <p-popover #columnChooser>\n <div class=\"column-chooser\">\n <label\n *ngFor=\"let col of chooserCols; trackBy: colTrackByFn\"\n data-testid=\"column-chooser-option\"\n class=\"column-chooser-option\"\n >\n <p-checkbox\n [binary]=\"true\"\n [ngModel]=\"isColumnVisible(col)\"\n [disabled]=\"!canToggleColumn(col)\"\n (onChange)=\"toggleColumn(col, $event.checked)\"\n ></p-checkbox>\n <span>{{ col.name }}</span>\n </label>\n <button\n type=\"button\"\n data-testid=\"column-chooser-reset\"\n class=\"column-chooser-reset\"\n (click)=\"resetColumnLayout()\"\n >\n {{ t('ResetToDefault') }}\n </button>\n </div>\n </p-popover>\n </div>\n</ng-container>\n", styles: [".table-header{display:flex;justify-content:space-between;align-items:center}@media (max-width: 640px){.table-header{display:flex;flex-direction:column;align-items:start;gap:14px}}.spiderly-table .p-paginator{padding:1rem}@media (min-width: 1400px){.spiderly-table .p-paginator-left-content{position:absolute;padding:14px;left:0}}@media (min-width: 1400px){.spiderly-table .p-paginator-right-content{position:absolute;padding:14px;right:0}}.column-chooser{display:flex;flex-direction:column;gap:8px}.column-chooser-option{display:flex;align-items:center;gap:8px;cursor:pointer}.column-chooser-reset{border:none;background:none;color:inherit;font:inherit;cursor:pointer;padding:0;margin-top:4px;color:var(--p-primary-color);align-self:flex-start;font-size:.875rem}.column-chooser-reset:hover{text-decoration:underline}.column-menu{display:flex;flex-direction:column;min-width:11rem}.column-menu-item{display:flex;align-items:center;gap:8px;border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.column-menu-item .column-menu-item-label{flex:1}.column-menu-item:hover:not(:disabled){background:var(--p-content-hover-background)}.column-menu-item:disabled{color:var(--p-text-muted-color);cursor:default}:host .spiderly-table-container{scroll-margin-top:calc(var(--spiderly-viewport-top-inset, 0px) + 1rem)}:host ::ng-deep .clickable{cursor:pointer}:host ::ng-deep td.clickable:hover{text-decoration:underline}:host .column-menu-button{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:0 2px;opacity:0;font-size:.75rem}:host th:hover .column-menu-button,:host .column-menu-button:focus-visible{opacity:1}:host .table-views{display:flex;flex-wrap:wrap;gap:4px;padding-top:8px}:host .table-view{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:4px 10px;border-radius:var(--p-content-border-radius)}:host .table-view:hover{background:var(--p-content-hover-background)}:host .table-view-active{color:var(--p-primary-color);font-weight:600;background:var(--p-content-hover-background)}:host .frozen-column{position:sticky;z-index:1}:host td.frozen-column{background:var(--p-datatable-row-background, var(--p-content-background));transition:background-color var(--p-transition-duration, .2s)}:host tr:hover>td.frozen-column{background:var(--p-datatable-row-hover-background, inherit)}:host .frozen-column-last{box-shadow:2px 0 4px -2px #00000026}:host th{position:relative}:host .column-resizer{position:absolute;top:0;right:0;width:8px;height:100%;cursor:col-resize;-webkit-user-select:none;user-select:none}:host .selection-count{font-size:.75rem;color:var(--p-text-muted-color)}:host td.selection-cell{-webkit-user-select:none;user-select:none}:host td.cell-wrap{--spiderly-cell-overflow: visible;--spiderly-cell-white-space: normal}:host .cell-text{display:block;overflow:var(--spiderly-cell-overflow, hidden);text-overflow:ellipsis;white-space:var(--spiderly-cell-white-space, nowrap)}:host ::ng-deep .p-datatable-mask{background:color-mix(in srgb,var(--p-content-background, #ffffff) 65%,transparent);color:var(--p-primary-color, #334155);align-items:flex-start;padding-top:6rem}:host ::ng-deep .p-datatable-thead{position:unset!important}:host ::ng-deep .p-datatable-header{border-radius:6px 6px 0 0}:host ::ng-deep .p-datatable{border-radius:var(--p-content-border-radius);border:1px solid var(--p-datatable-border-color)}\n"] }]
6805
+ ], template: "<ng-container *transloco=\"let t\">\n <div\n #tableContainer\n class=\"spiderly-table-container\"\n [attr.aria-busy]=\"isPending\"\n [ngClass]=\"{\n 'card responsive-card-padding overflow-auto': showCardWrapper\n }\"\n >\n <p-table\n #dt\n [value]=\"items\"\n [rows]=\"rows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [rowHover]=\"true\"\n [paginator]=\"showPaginator\"\n responsiveLayout=\"scroll\"\n [lazy]=\"hasLazyLoad\"\n (onLazyLoad)=\"lazyLoad($event)\"\n (onPage)=\"onPageChange(tableContainer)\"\n [totalRecords]=\"totalRecords\"\n class=\"spiderly-table\"\n [loading]=\"isPending\"\n [selectionMode]=\"selectionMode\"\n dataKey=\"id\"\n sortMode=\"multiple\"\n [multiSortMeta]=\"initialMultiSortMeta\"\n [stateKey]=\"resolvedStateKey\"\n [stateStorage]=\"stateStorage\"\n [tableStyle]=\"tableStyle\"\n >\n <ng-template pTemplate=\"caption\">\n <div class=\"table-header overflow-auto\">\n <div style=\"display: flex; align-items: center; gap: 8px\">\n <i class=\"{{ tableIcon }}\" style=\"font-size: 20px\"></i>\n <div style=\"margin: 0px; font-size: 17.5px\">{{ tableTitle }}</div>\n </div>\n <div style=\"display: flex; gap: 8px\">\n <ng-container\n *ngIf=\"actionsTemplate\"\n [ngTemplateOutlet]=\"actionsTemplate\"\n ></ng-container>\n <button\n pButton\n *ngIf=\"hasLazyLoad\"\n data-testid=\"column-chooser-button\"\n [label]=\"t('Columns')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-sliders-h\"\n (click)=\"columnChooser.toggle($event)\"\n ></button>\n <button\n pButton\n *ngIf=\"showExportToExcelButton\"\n [label]=\"t('ExportToExcel')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-download\"\n (click)=\"exportListToExcel()\"\n ></button>\n <button\n pButton\n *ngIf=\"showReloadTableButton\"\n [label]=\"t('Reload')\"\n class=\"p-button-outlined\"\n style=\"flex: none\"\n icon=\"pi pi-refresh\"\n (click)=\"reload()\"\n ></button>\n <button\n pButton\n *ngIf=\"deleteListFromTableObservableMethod && rowsSelectedNumber > 0\"\n [label]=\"t('DeleteSelected') + ' (' + rowsSelectedNumber + ')'\"\n class=\"p-button-danger\"\n style=\"flex: none\"\n icon=\"pi pi-trash\"\n (click)=\"deleteSelectedObjects()\"\n ></button>\n </div>\n </div>\n <!-- ONE menu for every column, with the target held in `menuColumn`. Rendering its items\n inside an @if keyed on the column would put them inside the popover's projected\n content, and PrimeNG does not re-project content an @if has destroyed \u2014 the filter\n bar's editor lost a whole afternoon to exactly that. -->\n <p-popover #columnMenu>\n <div class=\"column-menu\" role=\"menu\">\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-hide\"\n [disabled]=\"!menuColumn || !canToggleColumn(menuColumn)\"\n (click)=\"hideMenuColumn()\"\n >\n <i class=\"pi pi-eye-slash\"></i>\n <span class=\"column-menu-item-label\">{{ t('HideColumn') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-filter\"\n [disabled]=\"!menuColumn || !canFilterColumn(menuColumn)\"\n (click)=\"filterMenuColumn()\"\n >\n <i class=\"pi pi-filter\"></i>\n <span class=\"column-menu-item-label\">{{ t('Filter') }}\u2026</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-asc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(1)\"\n >\n <i class=\"pi pi-sort-amount-up-alt\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortAscending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-sort-desc\"\n [disabled]=\"!menuColumn || !isColumnSortable(menuColumn)\"\n (click)=\"sortMenuColumn(-1)\"\n >\n <i class=\"pi pi-sort-amount-down\"></i>\n <span class=\"column-menu-item-label\">{{ t('SortDescending') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-left\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, -1)\"\n (click)=\"moveMenuColumn(-1)\"\n >\n <i class=\"pi pi-arrow-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveLeft') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-right\"\n [disabled]=\"!menuColumn || !canMoveColumn(menuColumn, 1)\"\n (click)=\"moveMenuColumn(1)\"\n >\n <i class=\"pi pi-arrow-right\"></i>\n <span class=\"column-menu-item-label\">{{ t('MoveRight') }}</span>\n </button>\n <button\n type=\"button\"\n role=\"menuitem\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-fit\"\n (click)=\"fitMenuColumn()\"\n >\n <i class=\"pi pi-arrows-h\"></i>\n <span class=\"column-menu-item-label\">{{ t('FitWidth') }}</span>\n </button>\n <!-- menuitemcheckbox, not menuitem: this one TOGGLES, and a label reading the same\n in both states leaves the operator to find out by clicking \u2014 which on a wide\n column means re-laying the grid out to learn what it was already set to. The\n check is the state; the label stays put, which is what the role is for.\n Disabled for an editable column: its cell holds an input, not clamped text, so\n the toggle could only persist a preference that changes nothing. -->\n <button\n type=\"button\"\n role=\"menuitemcheckbox\"\n class=\"column-menu-item\"\n data-testid=\"column-menu-wrap\"\n [disabled]=\"!menuColumn || menuColumn.editable === true\"\n [attr.aria-checked]=\"isMenuColumnWrapped\"\n (click)=\"toggleWrapForMenuColumn()\"\n >\n <i class=\"pi pi-align-left\"></i>\n <span class=\"column-menu-item-label\">{{ t('WrapText') }}</span>\n <i *ngIf=\"isMenuColumnWrapped\" class=\"pi pi-check\"></i>\n </button>\n </div>\n </p-popover>\n\n <div class=\"table-views\" *ngIf=\"views?.length\" role=\"tablist\">\n <button\n *ngFor=\"let view of views\"\n type=\"button\"\n role=\"tab\"\n class=\"table-view\"\n data-testid=\"table-view\"\n [class.table-view-active]=\"activeViewId === view.id\"\n [attr.aria-selected]=\"activeViewId === view.id\"\n (click)=\"selectView(view)\"\n >\n {{ view.label }}\n </button>\n </div>\n\n <spiderly-filter-bar\n *ngIf=\"filters\"\n [filters]=\"filters\"\n [totalRecords]=\"totalRecords\"\n [sort]=\"sortKeys\"\n [sortOptions]=\"sortPickOptions\"\n [sortIsDefault]=\"sortIsDefault\"\n [controlTemplates]=\"filterTemplates\"\n (clearAll)=\"clear()\"\n (sortPick)=\"sortFromBar($event)\"\n (sortReset)=\"resetSortFromBar()\"\n ></spiderly-filter-bar>\n </ng-template>\n <ng-template pTemplate=\"header\">\n <tr>\n <!-- Sized for the checkbox plus the counter beside it, which shows `totalRecords` on a\n select-all and so reaches five digits on a large table. Not `0rem` (see CLAUDE.md \u2192\n Column widths), and not routed through getColWidth: this column has no `Column`. -->\n <th\n #selectionHeader\n class=\"frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.width]=\"selectionColumnWidth\"\n [style.left]=\"'0'\"\n *ngIf=\"selectionMode == 'multiple'\"\n >\n <div style=\"display: flex; gap: 8px\">\n <p-checkbox\n *ngIf=\"showSelectAllCheckbox\"\n [disabled]=\"readonly\"\n (onChange)=\"selectAll($event.checked)\"\n [(ngModel)]=\"fakeIsAllSelected\"\n [binary]=\"true\"\n ></p-checkbox>\n <span class=\"selection-count\">({{ rowsSelectedNumber }})</span>\n </div>\n </th>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <th\n #headerCell\n [pSortableColumn]=\"col.field\"\n [pSortableColumnDisabled]=\"!isColumnSortable(col)\"\n [style.width]=\"getColWidth(col)\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n [attr.draggable]=\"hasLazyLoad && !col.lockVisible ? true : null\"\n (mousedown)=\"onHeaderMouseDown($event)\"\n (dragstart)=\"onHeaderDragStart(col, $event)\"\n (dragover)=\"onHeaderDragOver(col, $event)\"\n (drop)=\"onHeaderDrop(col, $event)\"\n >\n <!-- Ours, not PrimeNG's: enabling [resizableColumns] makes it persist widths by\n th:nth-child INDEX into our own stateKey, which slides every stored width onto\n the wrong column the first time the chooser hides one (CLAUDE.md -> decision\n 5). We take the gesture and keep the state. -->\n <span\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n class=\"column-resizer\"\n data-testid=\"column-resizer\"\n (mousedown)=\"startColumnResize(col, $event, headerCell)\"\n ></span>\n <div\n style=\"\n display: flex;\n justify-content: space-between;\n align-items: center;\n \"\n >\n <span style=\"display: flex; align-items: center; gap: 4px\">\n {{ col.name }}\n <p-sortIcon\n *ngIf=\"isColumnSortable(col)\"\n [field]=\"col.field!\"\n ></p-sortIcon>\n <!-- stopPropagation is load-bearing: the th carries pSortableColumn, so a click\n that reaches it sorts the grid. Opening a menu must not reorder the table on\n the way. -->\n <button\n *ngIf=\"hasLazyLoad && isDataColumn(col)\"\n type=\"button\"\n class=\"column-menu-button\"\n data-testid=\"column-menu\"\n [attr.aria-label]=\"t('Columns')\"\n (click)=\"$event.stopPropagation(); openColumnMenu(col, $event)\"\n >\n <i class=\"pi pi-chevron-down\"></i>\n </button>\n </span>\n </div>\n </th>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template\n pTemplate=\"body\"\n let-rowData\n let-index=\"rowIndex\"\n let-editing=\"editing\"\n >\n <!-- The cell rendering every column gets unless it projects a spiderlyCellTemplate. Declared\n once here rather than inside the column loop, and fed the same context the projected\n template gets, so getRowData is evaluated exactly once per cell either way. -->\n <ng-template #defaultCell let-col=\"col\" let-displayValue=\"displayValue\">\n <ng-container *ngIf=\"col.filterType === 'blob'\">\n <img width=\"45\" [src]=\"displayValue\" alt=\"\" />\n </ng-container>\n <ng-container *ngIf=\"col.filterType !== 'blob'\">\n <span spiderlyOverflowTitle class=\"cell-text\">{{\n displayValue\n }}</span>\n </ng-container>\n </ng-template>\n <tr\n [class.clickable]=\"navigateOnRowClick\"\n (click)=\"onRowClick(rowData, $event)\"\n >\n <!-- row-interactive: a selection gesture must never double as row navigation (onRowClick). -->\n <td\n *ngIf=\"selectionMode == 'multiple'\"\n class=\"selection-cell row-interactive frozen-column\"\n [class.frozen-column-last]=\"isSelectionColumnLastFrozen\"\n [style.left]=\"'0'\"\n (mousedown)=\"onSelectionCellMouseDown($event, rowData[idField])\"\n >\n <p-checkbox\n [disabled]=\"readonly\"\n (onChange)=\"selectRow(rowData[idField], rowData.index)\"\n [ngModel]=\"isRowSelected(rowData[idField])\"\n [binary]=\"true\"\n ></p-checkbox>\n </td>\n <ng-container *ngFor=\"let col of visibleCols; trackBy: colTrackByFn\">\n <td\n [style]=\"getStyleForBodyColumn(col)\"\n [class.clickable]=\"col.onCellClick\"\n [class.row-interactive]=\"col.onCellClick\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [class.cell-wrap]=\"isColumnWrapped(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n (click)=\"onCellClick(col, rowData, $event)\"\n *ngIf=\"!col.editable\"\n >\n <!-- getColWidth reserves this column's width from `actions.length`; this gap is\n the geometry it reserves for, so changing it belongs in the same edit. -->\n <div\n style=\"\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 18px;\n \"\n >\n <ng-container\n *ngFor=\"let action of col.actions; trackBy: actionTrackByFn\"\n >\n <span\n [pTooltip]=\"action.name\"\n [class]=\"getClassForAction(action) + ' row-interactive'\"\n [style]=\"getStyleForAction(action)\"\n (click)=\"getMethodForAction(action, rowData, $event)\"\n ></span>\n </ng-container>\n </div>\n <ng-container\n [ngTemplateOutlet]=\"getCellTemplate(col) ?? defaultCell\"\n [ngTemplateOutletContext]=\"{\n $implicit: rowData,\n value: rowData[col.field],\n displayValue: getRowData(rowData, col),\n col: col\n }\"\n ></ng-container>\n </td>\n <td\n *ngIf=\"col.editable\"\n class=\"row-interactive\"\n [class.frozen-column]=\"isColumnFrozen(col)\"\n [class.frozen-column-last]=\"isLastFrozenColumn(col)\"\n [style.left]=\"isColumnFrozen(col) ? frozenColumnOffset() : null\"\n >\n <spiderly-number\n [control]=\"getFormArrayControlByIndex(col.field, rowData.index)\"\n [showLabel]=\"false\"\n ></spiderly-number>\n </td>\n </ng-container>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"emptymessage\">\n <tr>\n <td\n [attr.colspan]=\"\n visibleCols.length + (selectionMode === 'multiple' ? 1 : 0)\n \"\n >\n {{ t(\"NoRecordsFound\") }}\n </td>\n </tr>\n </ng-template>\n <ng-template pTemplate=\"paginatorleft\">\n {{ t(\"TotalRecords\") }}: {{ totalRecords }}\n </ng-template>\n <ng-template pTemplate=\"paginatorright\">\n <div style=\"display: flex; justify-content: end; gap: 10px\">\n <spiderly-button\n *ngIf=\"showAddButton\"\n [label]=\"t('AddNew')\"\n icon=\"pi pi-plus\"\n (onClick)=\"navigateToDetails(0)\"\n ></spiderly-button>\n </div>\n </ng-template>\n </p-table>\n <p-popover #columnChooser>\n <div class=\"column-chooser\">\n <label\n *ngFor=\"let col of chooserCols; trackBy: colTrackByFn\"\n data-testid=\"column-chooser-option\"\n class=\"column-chooser-option\"\n >\n <p-checkbox\n [binary]=\"true\"\n [ngModel]=\"isColumnVisible(col)\"\n [disabled]=\"!canToggleColumn(col)\"\n (onChange)=\"toggleColumn(col, $event.checked)\"\n ></p-checkbox>\n <span>{{ col.name }}</span>\n </label>\n <button\n type=\"button\"\n data-testid=\"column-chooser-reset\"\n class=\"column-chooser-reset\"\n (click)=\"resetColumnLayout()\"\n >\n {{ t('ResetToDefault') }}\n </button>\n </div>\n </p-popover>\n </div>\n</ng-container>\n", styles: [".table-header{display:flex;justify-content:space-between;align-items:center}@media (max-width: 640px){.table-header{display:flex;flex-direction:column;align-items:start;gap:14px}}.spiderly-table .p-paginator{padding:1rem}@media (min-width: 1400px){.spiderly-table .p-paginator-left-content{position:absolute;padding:14px;left:0}}@media (min-width: 1400px){.spiderly-table .p-paginator-right-content{position:absolute;padding:14px;right:0}}.column-chooser{display:flex;flex-direction:column;gap:8px}.column-chooser-option{display:flex;align-items:center;gap:8px;cursor:pointer}.column-chooser-reset{border:none;background:none;color:inherit;font:inherit;cursor:pointer;padding:0;margin-top:4px;color:var(--p-primary-color);align-self:flex-start;font-size:.875rem}.column-chooser-reset:hover{text-decoration:underline}.column-menu{display:flex;flex-direction:column;min-width:11rem}.column-menu-item{display:flex;align-items:center;gap:8px;border:none;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left;padding:6px 10px;border-radius:var(--p-content-border-radius)}.column-menu-item .column-menu-item-label{flex:1}.column-menu-item:hover:not(:disabled){background:var(--p-content-hover-background)}.column-menu-item:disabled{color:var(--p-text-muted-color);cursor:default}:host .spiderly-table-container{scroll-margin-top:calc(var(--spiderly-viewport-top-inset, 0px) + 1rem)}:host ::ng-deep .clickable{cursor:pointer}:host ::ng-deep td.clickable:hover{text-decoration:underline}:host .column-menu-button{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:0 2px;opacity:0;font-size:.75rem}:host th:hover .column-menu-button,:host .column-menu-button:focus-visible{opacity:1}:host .table-views{display:flex;flex-wrap:wrap;gap:4px;padding-top:8px}:host .table-view{border:none;background:none;color:inherit;font:inherit;cursor:pointer;color:var(--p-text-muted-color);padding:4px 10px;border-radius:var(--p-content-border-radius)}:host .table-view:hover{background:var(--p-content-hover-background)}:host .table-view-active{color:var(--p-primary-color);font-weight:600;background:var(--p-content-hover-background)}:host .frozen-column{position:sticky;z-index:1}:host td.frozen-column{background:var(--p-datatable-row-background, var(--p-content-background));transition:background-color var(--p-transition-duration, .2s)}:host tr:hover>td.frozen-column{background:var(--p-datatable-row-hover-background, inherit)}:host .frozen-column-last{box-shadow:2px 0 4px -2px #00000026}:host th{position:relative}:host .column-resizer{position:absolute;top:0;right:0;width:8px;height:100%;cursor:col-resize;-webkit-user-select:none;user-select:none}:host .selection-count{font-size:.75rem;color:var(--p-text-muted-color)}:host td.selection-cell{-webkit-user-select:none;user-select:none}:host td.cell-wrap{--spiderly-cell-overflow: visible;--spiderly-cell-white-space: normal}:host .cell-text{display:block;overflow:var(--spiderly-cell-overflow, hidden);text-overflow:ellipsis;white-space:var(--spiderly-cell-white-space, nowrap)}:host ::ng-deep .p-datatable-mask{background:color-mix(in srgb,var(--p-content-background, #ffffff) 65%,transparent);color:var(--p-primary-color, #334155);align-items:flex-start;padding-top:6rem}:host ::ng-deep .p-datatable-thead{position:unset!important}:host ::ng-deep .p-datatable-header{border-radius:6px 6px 0 0}:host ::ng-deep .p-datatable{border-radius:var(--p-content-border-radius);border:1px solid var(--p-datatable-border-color)}\n"] }]
6309
6806
  }], ctorParameters: () => [{ type: i3$2.Router }, { type: i1$5.DialogService }, { type: i3$2.ActivatedRoute }, { type: SpiderlyMessageService }, { type: i1.TranslocoService }, { type: ConfigServiceBase }, { type: i0.ErrorHandler }, { type: undefined, decorators: [{
6310
6807
  type: Inject,
6311
6808
  args: [LOCALE_ID]
@@ -6415,12 +6912,13 @@ class Action {
6415
6912
  }
6416
6913
  }
6417
6914
  class Column {
6418
- constructor({ name, field, filterType, filterId, showMatchModes, dropdownOrMultiselectValues, actions, editable, showTime, decimalPlaces, sortable, visible, lockVisible, width, onCellClick, } = {}) {
6915
+ constructor({ name, field, filterType, filterId, showMatchModes, filterField, dropdownOrMultiselectValues, actions, editable, showTime, decimalPlaces, sortable, visible, lockVisible, width, onCellClick, } = {}) {
6419
6916
  this.name = name;
6420
6917
  this.field = field;
6421
6918
  this.filterType = filterType;
6422
6919
  this.filterId = filterId;
6423
6920
  this.showMatchModes = showMatchModes;
6921
+ this.filterField = filterField;
6424
6922
  this.dropdownOrMultiselectValues = dropdownOrMultiselectValues;
6425
6923
  this.actions = actions;
6426
6924
  this.editable = editable;
@@ -6462,10 +6960,11 @@ var MatchModeCodes;
6462
6960
  * paginator implements per type (`PaginatedResultGenerator`): `In` on a string column answers
6463
6961
  * with InvalidMatchMode, PrimeNG's own text list adds three modes the backend 400s, and so on.
6464
6962
  *
6465
- * Deliberately a LEAF module (imports nothing but the codes) so both consumers can derive from
6466
- * it without a cycle: the store's runtime validation and offered lists (`filter-store.ts`), and
6467
- * the compile-time `FilterRule.matchMode` union (`entities/filter-rule.ts`). These used to be
6468
- * hand-kept copies; edit the table here and every surface follows.
6963
+ * Deliberately a LEAF module (imports nothing but the codes) so every consumer can derive from
6964
+ * it without a cycle: the store's runtime validation and offered lists (`filter-store.ts`), the
6965
+ * compile-time `FilterRule.matchMode` union (`entities/filter-rule.ts`), and the data view's
6966
+ * kind-level lists (via `operatorOptionsForKind`). These used to be hand-kept copies; edit the
6967
+ * table here and every surface follows.
6469
6968
  *
6470
6969
  * Tuple order is DISPLAY order for every operator picker (Equals, then "less/before", then
6471
6970
  * "more/after" — the order the header dropdowns always shipped).
@@ -6563,6 +7062,17 @@ function toOperatorOptions(kind, operators) {
6563
7062
  labelKey: OPERATOR_WORDS[kind][value]?.pickerKey ?? value,
6564
7063
  }));
6565
7064
  }
7065
+ /**
7066
+ * A KIND's operator picker options, for callers with no filter behind them — today that is
7067
+ * `spiderly-data-view`, whose own filter config derives its operator lists here instead of
7068
+ * hand-keeping copies (the drift `allowed-operators.ts` exists to end). Per-FILTER narrowing
7069
+ * lives in the store (`createFilterStore` resolves it once per declaration), so this cannot be
7070
+ * handed a fake definition to satisfy — that shape shipped once and interpolated an empty
7071
+ * filter name into its own error message.
7072
+ */
7073
+ function operatorOptionsForKind(kind, hasOptions = false) {
7074
+ return toOperatorOptions(kind, baseOperators(kind, hasOptions));
7075
+ }
6566
7076
  /**
6567
7077
  * The operator applied when nobody picked one, which has to be what a person MEANS by filling in
6568
7078
  * the control. `Contains` for a fragment typed into a box; `GreaterThan` for a date, never
@@ -6916,34 +7426,15 @@ class SpiderlyDataViewComponent {
6916
7426
  };
6917
7427
  }
6918
7428
  ngOnInit() {
6919
- this.matchModeDateOptions = [
6920
- {
6921
- label: this.translocoService.translate('OnDate'),
6922
- value: MatchModeCodes.Equals,
6923
- },
6924
- {
6925
- label: this.translocoService.translate('DatesBefore'),
6926
- value: MatchModeCodes.LessThan,
6927
- },
6928
- {
6929
- label: this.translocoService.translate('DatesAfter'),
6930
- value: MatchModeCodes.GreaterThan,
6931
- },
6932
- ];
6933
- this.matchModeNumberOptions = [
6934
- {
6935
- label: this.translocoService.translate('Equals'),
6936
- value: MatchModeCodes.Equals,
6937
- },
6938
- {
6939
- label: this.translocoService.translate('LessThan'),
6940
- value: MatchModeCodes.LessThan,
6941
- },
6942
- {
6943
- label: this.translocoService.translate('MoreThan'),
6944
- value: MatchModeCodes.GreaterThan,
6945
- },
6946
- ];
7429
+ // Derived from the ONE operator table (`filters/allowed-operators.ts`) rather than
7430
+ // hand-kept: these were the last hand-written copies of the per-kind lists, and a backend
7431
+ // operator change edited in the shared table would silently never have reached them.
7432
+ const matchModeOptionsFor = (kind) => operatorOptionsForKind(kind).map((option) => ({
7433
+ label: this.translocoService.translate(option.labelKey),
7434
+ value: option.value,
7435
+ }));
7436
+ this.matchModeDateOptions = matchModeOptionsFor('date');
7437
+ this.matchModeNumberOptions = matchModeOptionsFor('number');
6947
7438
  }
6948
7439
  // Paging semantics and the scroll-margin contract are documented once, on the data table:
6949
7440
  // spiderly-data-table/CLAUDE.md → "Scroll back to the table on page change".
@@ -7584,5 +8075,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.13", ngImpo
7584
8075
  * Generated bundle index. Do not edit.
7585
8076
  */
7586
8077
 
7587
- export { ALLOWED_OPERATORS, Action, AllClickEvent, ApiErrorCodes, ApiSecurityService, AppSidebarComponent, AuthCardComponent, AuthGuard, AuthResult, AuthResultWithCookies, AuthServiceBase, BaseAutocompleteControl, BaseControl, BaseDropdownControl, BaseEntity, BaseFormComponent, BaseFormService, CardSkeletonComponent, Codebook, Column, ConfigServiceBase, DEFAULT_EXTERNAL_PROVIDER_ICONS, DEFAULT_OPERATOR, ExternalLoginComponent, ExternalProvider, ExternalProviderPublic, Filter, FilterRule, FilterSortMeta, IndexCardComponent, InfoCardComponent, InitCompanyAuthDialogDetails, InitTopBarData, IsAuthorizedForSaveEvent, LastMenuIconIndexClicked, LayoutServiceBase, LazyLoadSelectedIdsResult, Login, LoginVerificationComponent, LoginVerificationToken, MatchModeCodes, MenuChangeEvent, MenuitemComponent, Namebook, NotAuthGuard, NotFoundComponent, PROPS_KEY, PaginatedResult, PanelBodyComponent, PanelFooterComponent, PanelHeaderComponent, PrimengOption, ProfileAvatarComponent, ReflectProp, RefreshTokenRequest, RequiredComponent, RowClickEvent, SecurityPermissionCodes, SendLoginVerificationEmailResult, SideMenuTopBarComponent, SidebarMenuComponent, SimpleSaveResult, SpiderlyAutocompleteComponent, SpiderlyButtonBaseComponent, SpiderlyButtonComponent, SpiderlyCalendarComponent, SpiderlyCardComponent, SpiderlyCellTemplateDirective, SpiderlyCheckboxComponent, SpiderlyColorPickerComponent, SpiderlyControlsModule, SpiderlyDataTableActionsDirective, SpiderlyDataTableComponent, SpiderlyDataViewComponent, SpiderlyDeleteConfirmationComponent, SpiderlyDropdownComponent, SpiderlyEditorComponent, SpiderlyErrorHandler, SpiderlyFileComponent, SpiderlyFileSelectEvent, SpiderlyFilterBarComponent, SpiderlyFilterTemplateDirective, SpiderlyFormArray, SpiderlyFormControl, SpiderlyFormGroup, SpiderlyLayoutComponent, SpiderlyLoginComponent, SpiderlyMarkdownComponent, SpiderlyMessageService, SpiderlyMultiAutocompleteComponent, SpiderlyMultiSelectComponent, SpiderlyNumberComponent, SpiderlyOverflowTitleDirective, SpiderlyPanelComponent, SpiderlyPanelsModule, SpiderlyPasswordComponent, SpiderlyReturnButtonComponent, SpiderlySplitButtonComponent, SpiderlyTab, SpiderlyTemplateTypeDirective, SpiderlyTextareaComponent, SpiderlyTextboxComponent, SpiderlyTranslocoLoader, TopBarComponent, UserBase, UserRole, ValidatorAbstractService, VerificationTokenRequest, VerificationTypeCodes, VerificationWrapperComponent, adjustColor, authInitializer, booleanFilter, capitalizeFirstChar, createFilterStore, dateFilter, deleteAction, exportListToExcel, firstCharToUpper, foldForSearch, getFileNameFromContentDisposition, getFilterOptions, getHtmlImgDisplayString64, getImageDimensions, getMimeTypeForFileName, getMonth, getParentUrl, getPrimengAutocompleteCodebookOptions, getPrimengAutocompleteNamebookOptions, getPrimengDropdownCodebookOptions, getPrimengDropdownNamebookOptions, getPrimengNamebookOptions, httpLoadingInterceptor, isExcelFileType, isFileImageType, isNullOrEmpty, jsonHttpInterceptor, jwtInterceptor, kebabToTitleCase, nameOf, nameof, numberFilter, parseDateOnlyLocal, pushAction, saveResponseAsFile, scrollElementIntoViewIfAboveViewport, selectedTab, singleOrDefault, splitPascalCase, textFilter, toCommaSeparatedString, unauthorizedInterceptor, validatePrecisionScale };
8078
+ export { ALLOWED_OPERATORS, Action, AllClickEvent, ApiErrorCodes, ApiSecurityService, AppSidebarComponent, AuthCardComponent, AuthGuard, AuthResult, AuthResultWithCookies, AuthServiceBase, BaseAutocompleteControl, BaseControl, BaseDropdownControl, BaseEntity, BaseFormComponent, BaseFormService, CardSkeletonComponent, Codebook, Column, ConfigServiceBase, DEFAULT_EXTERNAL_PROVIDER_ICONS, DEFAULT_OPERATOR, ExternalLoginComponent, ExternalProvider, ExternalProviderPublic, Filter, FilterRule, FilterSortMeta, IndexCardComponent, InfoCardComponent, InitCompanyAuthDialogDetails, InitTopBarData, IsAuthorizedForSaveEvent, LastMenuIconIndexClicked, LayoutServiceBase, LazyLoadSelectedIdsResult, Login, LoginVerificationComponent, LoginVerificationToken, MatchModeCodes, MenuChangeEvent, MenuitemComponent, Namebook, NotAuthGuard, NotFoundComponent, PROPS_KEY, PaginatedResult, PanelBodyComponent, PanelFooterComponent, PanelHeaderComponent, PrimengOption, ProfileAvatarComponent, ReflectProp, RefreshTokenRequest, RequiredComponent, RowClickEvent, SecurityPermissionCodes, SendLoginVerificationEmailResult, SideMenuTopBarComponent, SidebarMenuComponent, SimpleSaveResult, SpiderlyAutocompleteComponent, SpiderlyButtonBaseComponent, SpiderlyButtonComponent, SpiderlyCalendarComponent, SpiderlyCardComponent, SpiderlyCellTemplateDirective, SpiderlyCheckboxComponent, SpiderlyColorPickerComponent, SpiderlyControlsModule, SpiderlyDataTableActionsDirective, SpiderlyDataTableComponent, SpiderlyDataViewComponent, SpiderlyDeleteConfirmationComponent, SpiderlyDropdownComponent, SpiderlyEditorComponent, SpiderlyErrorHandler, SpiderlyFileComponent, SpiderlyFileSelectEvent, SpiderlyFilterBarComponent, SpiderlyFilterTemplateDirective, SpiderlyFormArray, SpiderlyFormControl, SpiderlyFormGroup, SpiderlyLayoutComponent, SpiderlyLoginComponent, SpiderlyMarkdownComponent, SpiderlyMessageService, SpiderlyMultiAutocompleteComponent, SpiderlyMultiSelectComponent, SpiderlyNumberComponent, SpiderlyOverflowTitleDirective, SpiderlyPanelComponent, SpiderlyPanelsModule, SpiderlyPasswordComponent, SpiderlyReturnButtonComponent, SpiderlySplitButtonComponent, SpiderlyTab, SpiderlyTemplateTypeDirective, SpiderlyTextareaComponent, SpiderlyTextboxComponent, SpiderlyTranslocoLoader, TopBarComponent, UserBase, UserRole, ValidatorAbstractService, VerificationTokenRequest, VerificationTypeCodes, VerificationWrapperComponent, adjustColor, authInitializer, booleanFilter, capitalizeFirstChar, createFilterStore, dateFilter, deleteAction, exportListToExcel, firstCharToUpper, foldForSearch, getFileNameFromContentDisposition, getFilterOptions, getHtmlImgDisplayString64, getImageDimensions, getMimeTypeForFileName, getMonth, getParentUrl, getPrimengAutocompleteCodebookOptions, getPrimengAutocompleteNamebookOptions, getPrimengDropdownCodebookOptions, getPrimengDropdownNamebookOptions, getPrimengNamebookOptions, httpLoadingInterceptor, isExcelFileType, isFileImageType, isNullOrEmpty, jsonHttpInterceptor, jwtInterceptor, kebabToTitleCase, nameOf, nameof, numberFilter, operatorOptionsForKind, parseDateOnlyLocal, pushAction, saveResponseAsFile, scrollElementIntoViewIfAboveViewport, selectedTab, singleOrDefault, splitPascalCase, textFilter, toCommaSeparatedString, unauthorizedInterceptor, validatePrecisionScale };
7588
8079
  //# sourceMappingURL=spiderly.mjs.map