@truenas/ui-components 0.7.9 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { Overlay, OverlayPositionBuilder, OverlayModule } from '@angular/cdk/overlay';
2
- import { TemplatePortal, ComponentPortal, PortalModule } from '@angular/cdk/portal';
1
+ import { Overlay, OverlayPositionBuilder, OverlayModule, createOverlayRef, createNoopScrollStrategy, createGlobalPositionStrategy } from '@angular/cdk/overlay';
2
+ import { TemplatePortal, ComponentPortal, PortalModule, DomPortal } from '@angular/cdk/portal';
3
3
  import * as i0 from '@angular/core';
4
4
  import { InjectionToken, inject, computed, isSignal, isDevMode, effect, signal, input, ViewEncapsulation, ChangeDetectionStrategy, Component, Injector, Renderer2, ElementRef, Directive, DestroyRef, untracked, ViewContainerRef, output, viewChild, forwardRef, NgZone, afterNextRender, model, Injectable, contentChildren, Pipe, HostListener, linkedSignal, contentChild, TemplateRef, viewChildren, ChangeDetectorRef, afterEveryRender, IterableDiffers, booleanAttribute, LOCALE_ID, ApplicationRef, EnvironmentInjector, createComponent, PLATFORM_ID } from '@angular/core';
5
5
  import * as i1$3 from '@angular/forms';
@@ -1732,18 +1732,44 @@ class TnAutocompleteComponent {
1732
1732
  }
1733
1733
  break;
1734
1734
  }
1735
- case 'Escape': {
1736
- event.preventDefault();
1737
- if (this.allowCustomValue()) {
1738
- // Escape means "cancel the draft": revert to the committed value's
1739
- // text so the upcoming blur doesn't commit the abandoned term.
1740
- this.revertDisplayToValue(this.selectedValue());
1741
- }
1742
- this.close();
1743
- break;
1744
- }
1735
+ // Escape is not here: it is handled on this component's HOST element
1736
+ // instead, because it has to consume the key — see `onHostKeydown`.
1745
1737
  }
1746
1738
  }
1739
+ /**
1740
+ * Escape, which cancels the draft, dismisses the panel, and — while the panel
1741
+ * is open — CONSUMES the key.
1742
+ *
1743
+ * Consuming matters because of what is usually underneath: since #322 a
1744
+ * `tn-side-panel` is a CDK overlay listening for Escape through
1745
+ * `OverlayKeyboardDispatcher`, which delivers to the top-most attached
1746
+ * overlay that has subscribers. Dismissing disposes this panel's overlay
1747
+ * mid-keystroke, taking it out of that stack — so an Escape left to carry on
1748
+ * reached `<body>`, found the side panel as the new top-most subscriber, and
1749
+ * closed the whole form along with the panel (#324). A `tn-drawer` in `over`
1750
+ * mode has a keydown handler of its own and closed the same way.
1751
+ *
1752
+ * With the panel already closed the key is NOT consumed: there is nothing to
1753
+ * dismiss and it belongs to whatever contains this field. The draft still
1754
+ * reverts, which is what the blur that follows would otherwise commit.
1755
+ *
1756
+ * Why this sits on the HOST rather than beside the other keys on the input:
1757
+ * `stopPropagation()` stops ANCESTOR listeners, not other listeners on the
1758
+ * same element. Called from the input it also hid Escape from a `tnTooltip`
1759
+ * on this component, which dismisses a visible tooltip from a host listener —
1760
+ * so the tooltip stayed up. From here, that directive still sees the key and
1761
+ * only the containers ABOVE this field stop seeing it.
1762
+ */
1763
+ onHostKeydown(event) {
1764
+ if (event.key !== 'Escape') {
1765
+ return;
1766
+ }
1767
+ event.preventDefault();
1768
+ if (this.isOpen()) {
1769
+ event.stopPropagation();
1770
+ }
1771
+ this.dismiss();
1772
+ }
1747
1773
  /**
1748
1774
  * `loadMore` normally fires from a scroll event, which never happens when
1749
1775
  * the current page is too short to overflow the panel — pagination would
@@ -2050,6 +2076,39 @@ class TnAutocompleteComponent {
2050
2076
  }
2051
2077
  this.close();
2052
2078
  }));
2079
+ // Escape reaching the panel through CDK's keyboard dispatcher, which hands
2080
+ // the key to the top-most attached overlay that HAS subscribers and stops
2081
+ // there — so an overlay that subscribes is also one the key stops at,
2082
+ // rather than carrying on to a `tn-side-panel` underneath it (#324).
2083
+ //
2084
+ // NOT REACHABLE TODAY, and kept deliberately. It would take a keydown that
2085
+ // did not come from the input, and there is none: `onBlur` closes the panel
2086
+ // as soon as focus leaves the field, and every option row is
2087
+ // `tabindex="-1"` with its `mousedown` prevented. While focus IS in the
2088
+ // field, `onHostKeydown` consumes Escape before it can reach the
2089
+ // dispatcher's listener on `<body>`. This is what would keep the key
2090
+ // stopping here if the panel ever did take focus — the shape `tn-select`
2091
+ // has carried since long before #324.
2092
+ this.overlaySubs.push(this.overlayRef.keydownEvents().subscribe((event) => {
2093
+ if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
2094
+ event.preventDefault();
2095
+ this.dismiss();
2096
+ }
2097
+ }));
2098
+ }
2099
+ /**
2100
+ * Escape's effect on this field: cancel the draft, then close.
2101
+ *
2102
+ * Shared by the overlay subscription above and `onHostKeydown`, which is
2103
+ * where Escape arrives while focus is in the field — open panel or closed.
2104
+ */
2105
+ dismiss() {
2106
+ if (this.allowCustomValue()) {
2107
+ // Escape means "cancel the draft": revert to the committed value's text
2108
+ // so the upcoming blur doesn't commit the abandoned term.
2109
+ this.revertDisplayToValue(this.selectedValue());
2110
+ }
2111
+ this.close();
2053
2112
  }
2054
2113
  detachOverlay() {
2055
2114
  this.overlaySubs.forEach((sub) => sub.unsubscribe());
@@ -2069,7 +2128,7 @@ class TnAutocompleteComponent {
2069
2128
  }
2070
2129
  }
2071
2130
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnAutocompleteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2072
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnAutocompleteComponent, isStandalone: true, selector: "tn-autocomplete", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requireSelection: { classPropertyName: "requireSelection", publicName: "requireSelection", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, dataSourceDebounce: { classPropertyName: "dataSourceDebounce", publicName: "dataSourceDebounce", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, actionOption: { classPropertyName: "actionOption", publicName: "actionOption", isSignal: true, isRequired: false, transformFunction: null }, keepSelectedOption: { classPropertyName: "keepSelectedOption", publicName: "keepSelectedOption", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, filterFn: { classPropertyName: "filterFn", publicName: "filterFn", isSignal: true, isRequired: false, transformFunction: null }, noResultsText: { classPropertyName: "noResultsText", publicName: "noResultsText", isSignal: true, isRequired: false, transformFunction: null }, maxResults: { classPropertyName: "maxResults", publicName: "maxResults", isSignal: true, isRequired: false, transformFunction: null }, panelMaxHeight: { classPropertyName: "panelMaxHeight", publicName: "panelMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { optionSelected: "optionSelected", searchChange: "searchChange", actionSelected: "actionSelected", dataSourceError: "dataSourceError", loadMore: "loadMore", opened: "opened" }, providers: [
2131
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnAutocompleteComponent, isStandalone: true, selector: "tn-autocomplete", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requireSelection: { classPropertyName: "requireSelection", publicName: "requireSelection", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, dataSourceDebounce: { classPropertyName: "dataSourceDebounce", publicName: "dataSourceDebounce", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, actionOption: { classPropertyName: "actionOption", publicName: "actionOption", isSignal: true, isRequired: false, transformFunction: null }, keepSelectedOption: { classPropertyName: "keepSelectedOption", publicName: "keepSelectedOption", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, filterFn: { classPropertyName: "filterFn", publicName: "filterFn", isSignal: true, isRequired: false, transformFunction: null }, noResultsText: { classPropertyName: "noResultsText", publicName: "noResultsText", isSignal: true, isRequired: false, transformFunction: null }, maxResults: { classPropertyName: "maxResults", publicName: "maxResults", isSignal: true, isRequired: false, transformFunction: null }, panelMaxHeight: { classPropertyName: "panelMaxHeight", publicName: "panelMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { optionSelected: "optionSelected", searchChange: "searchChange", actionSelected: "actionSelected", dataSourceError: "dataSourceError", loadMore: "loadMore", opened: "opened" }, host: { listeners: { "keydown": "onHostKeydown($event)" } }, providers: [
2073
2132
  {
2074
2133
  provide: NG_VALUE_ACCESSOR,
2075
2134
  useExisting: forwardRef(() => TnAutocompleteComponent),
@@ -2085,7 +2144,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
2085
2144
  useExisting: forwardRef(() => TnAutocompleteComponent),
2086
2145
  multi: true,
2087
2146
  },
2088
- ], template: "<div class=\"tn-autocomplete\">\n <input\n #inputEl\n type=\"text\"\n class=\"tn-autocomplete__input\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n tnTestIdType=\"autocomplete\"\n autocomplete=\"off\"\n [tnTestId]=\"resolvedTestId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-controls]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-owns]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\" />\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. Attach/detach (driven by open()/close()) controls\n visibility; the input keeps DOM focus throughout. -->\n<ng-template #dropdownTemplate>\n <!-- The max-height is declared here, and inherits: it bounds the panel as a\n whole AND the listbox inside it, which are two elements now. -->\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\">\n\n <!-- Per ARIA, a listbox may only contain option/group children, so the\n loading and no-results rows live OUTSIDE it as live-region siblings.\n The listbox itself stays rendered (possibly empty) the whole time the\n panel is open, so the input's aria-controls always points at a real\n element \u2014 including the loading-with-no-results-yet state.\n\n The listbox is also the scrollport, and has to be (#292): axe's\n `scrollable-region-focusable` exempts a combobox popup, but it tests\n the scroll container's OWN role, so the role-less wrapper that used to\n carry the overflow was reported even though every option inside is\n `tabindex=\"-1\"` on purpose. The paging handler moves with the\n overflow \u2014 a scroll event fires on the element that scrolls \u2014 and the\n status row below stays outside the scrolling area. -->\n <div\n class=\"tn-autocomplete__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-dropdown'\"\n [attr.aria-busy]=\"resolvedLoading() ? true : null\"\n (scroll)=\"onPanelScroll($event)\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [class.tn-autocomplete__option--action]=\"option === actionOption()\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (resolvedLoading()) {\n <div\n class=\"tn-autocomplete__loading\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('loading')\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n } @else if (!hasMatches()) {\n <div\n class=\"tn-autocomplete__no-results\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('no-results')\">\n {{ resolvedNoResultsText() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-autocomplete{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-autocomplete__input{display:block;width:100%;min-height:2.5rem;padding:.5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;color:var(--tn-fg1, #212529);font-size:1rem;font-family:inherit;outline:none;box-sizing:border-box;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-autocomplete__input::placeholder{color:var(--tn-alt-fg1, #999)}.tn-autocomplete__input:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-autocomplete__input:focus-visible,.tn-autocomplete__input.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-autocomplete__input.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__dropdown{background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;padding:.25rem 0;display:flex;flex-direction:column;max-height:var(--tn-autocomplete-panel-max-height, 320px)}.tn-autocomplete__listbox{max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;min-height:0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__option--action{border-bottom:1px solid var(--tn-lines, #dee2e6);font-weight:500}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"] }]
2147
+ ], host: {
2148
+ '(keydown)': 'onHostKeydown($event)',
2149
+ }, template: "<div class=\"tn-autocomplete\">\n <input\n #inputEl\n type=\"text\"\n class=\"tn-autocomplete__input\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n tnTestIdType=\"autocomplete\"\n autocomplete=\"off\"\n [tnTestId]=\"resolvedTestId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-controls]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-owns]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\" />\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. Attach/detach (driven by open()/close()) controls\n visibility; the input keeps DOM focus throughout. -->\n<ng-template #dropdownTemplate>\n <!-- The max-height is declared here, and inherits: it bounds the panel as a\n whole AND the listbox inside it, which are two elements now. -->\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\">\n\n <!-- Per ARIA, a listbox may only contain option/group children, so the\n loading and no-results rows live OUTSIDE it as live-region siblings.\n The listbox itself stays rendered (possibly empty) the whole time the\n panel is open, so the input's aria-controls always points at a real\n element \u2014 including the loading-with-no-results-yet state.\n\n The listbox is also the scrollport, and has to be (#292): axe's\n `scrollable-region-focusable` exempts a combobox popup, but it tests\n the scroll container's OWN role, so the role-less wrapper that used to\n carry the overflow was reported even though every option inside is\n `tabindex=\"-1\"` on purpose. The paging handler moves with the\n overflow \u2014 a scroll event fires on the element that scrolls \u2014 and the\n status row below stays outside the scrolling area. -->\n <div\n class=\"tn-autocomplete__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-dropdown'\"\n [attr.aria-busy]=\"resolvedLoading() ? true : null\"\n (scroll)=\"onPanelScroll($event)\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [class.tn-autocomplete__option--action]=\"option === actionOption()\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (resolvedLoading()) {\n <div\n class=\"tn-autocomplete__loading\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('loading')\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n } @else if (!hasMatches()) {\n <div\n class=\"tn-autocomplete__no-results\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('no-results')\">\n {{ resolvedNoResultsText() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-autocomplete{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-autocomplete__input{display:block;width:100%;min-height:2.5rem;padding:.5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;color:var(--tn-fg1, #212529);font-size:1rem;font-family:inherit;outline:none;box-sizing:border-box;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-autocomplete__input::placeholder{color:var(--tn-alt-fg1, #999)}.tn-autocomplete__input:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-autocomplete__input:focus-visible,.tn-autocomplete__input.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-autocomplete__input.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__dropdown{background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;padding:.25rem 0;display:flex;flex-direction:column;max-height:var(--tn-autocomplete-panel-max-height, 320px)}.tn-autocomplete__listbox{max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;min-height:0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__option--action{border-bottom:1px solid var(--tn-lines, #dee2e6);font-weight:500}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"] }]
2089
2150
  }], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], requireSelection: [{ type: i0.Input, args: [{ isSignal: true, alias: "requireSelection", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], dataSource: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSource", required: false }] }], dataSourceDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSourceDebounce", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }], actionOption: [{ type: i0.Input, args: [{ isSignal: true, alias: "actionOption", required: false }] }], keepSelectedOption: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepSelectedOption", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], filterFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterFn", required: false }] }], noResultsText: [{ type: i0.Input, args: [{ isSignal: true, alias: "noResultsText", required: false }] }], maxResults: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxResults", required: false }] }], panelMaxHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelMaxHeight", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", required: false }] }], optionSelected: [{ type: i0.Output, args: ["optionSelected"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], actionSelected: [{ type: i0.Output, args: ["actionSelected"] }], dataSourceError: [{ type: i0.Output, args: ["dataSourceError"] }], loadMore: [{ type: i0.Output, args: ["loadMore"] }], opened: [{ type: i0.Output, args: ["opened"] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
2090
2151
 
2091
2152
  /**
@@ -8900,13 +8961,10 @@ class TnChipInputComponent {
8900
8961
  return;
8901
8962
  }
8902
8963
  if (event.key === 'Escape') {
8903
- if (this.isOpen()) {
8904
- event.preventDefault();
8905
- // Latched, so neither the re-open effect nor a `dataSource` response
8906
- // still in flight can undo the dismissal. See {@link closedByUser}.
8907
- this.closedByUser = true;
8908
- this.close();
8909
- }
8964
+ // Handled on this component's HOST element instead, because it has to
8965
+ // consume the key — see `onHostKeydown`. Returning rather than falling
8966
+ // through, so that a consumer who lists Escape in `separatorKeys` cannot
8967
+ // have it commit a chip on the way past.
8910
8968
  return;
8911
8969
  }
8912
8970
  if (this.isCommitKey(event)) {
@@ -8927,6 +8985,39 @@ class TnChipInputComponent {
8927
8985
  this.removeChip(this.values().length - 1);
8928
8986
  }
8929
8987
  }
8988
+ /**
8989
+ * Escape, which dismisses the suggestion panel and CONSUMES the key.
8990
+ *
8991
+ * Consuming matters because of what is usually underneath: since #322 a
8992
+ * `tn-side-panel` is a CDK overlay listening for Escape through
8993
+ * `OverlayKeyboardDispatcher`, which delivers to the top-most attached
8994
+ * overlay that has subscribers. Dismissing disposes this panel's overlay
8995
+ * mid-keystroke, taking it out of that stack — so an Escape left to carry on
8996
+ * reached `<body>`, found the side panel as the new top-most subscriber, and
8997
+ * closed the whole form along with the panel (#324). A `tn-drawer` in `over`
8998
+ * mode has a keydown handler of its own and closed the same way.
8999
+ *
9000
+ * With the panel already closed nothing happens here and the key belongs to
9001
+ * whatever contains this field — which is what it did before #324 too.
9002
+ *
9003
+ * Why this sits on the HOST rather than beside the other keys on the input:
9004
+ * `stopPropagation()` stops ANCESTOR listeners, not other listeners on the
9005
+ * same element. Called from the input it also hid Escape from a `tnTooltip`
9006
+ * on this component, which dismisses a visible tooltip from a host listener —
9007
+ * so the tooltip stayed up. From here, that directive still sees the key and
9008
+ * only the containers ABOVE this field stop seeing it.
9009
+ */
9010
+ onHostKeydown(event) {
9011
+ if (event.key !== 'Escape' || !this.isOpen()) {
9012
+ return;
9013
+ }
9014
+ event.preventDefault();
9015
+ event.stopPropagation();
9016
+ // Latched, so neither the re-open effect nor a `dataSource` response still
9017
+ // in flight can undo the dismissal. See {@link closedByUser}.
9018
+ this.closedByUser = true;
9019
+ this.close();
9020
+ }
8930
9021
  onSuggestionClick(option) {
8931
9022
  this.commitValue(option.value, option.label);
8932
9023
  this.inputEl().nativeElement.focus();
@@ -9230,6 +9321,28 @@ class TnChipInputComponent {
9230
9321
  }
9231
9322
  this.close();
9232
9323
  }));
9324
+ // Escape reaching the panel through CDK's keyboard dispatcher, which hands
9325
+ // the key to the top-most attached overlay that HAS subscribers and stops
9326
+ // there — so an overlay that subscribes is also one the key stops at,
9327
+ // rather than carrying on to a `tn-side-panel` underneath it (#324).
9328
+ //
9329
+ // NOT REACHABLE TODAY, and kept deliberately. It would take a keydown that
9330
+ // did not come from the input, and there is none: `onBlur` closes the panel
9331
+ // as soon as focus leaves the field, and every suggestion row is
9332
+ // `tabindex="-1"` with its `mousedown` prevented. While focus IS in the
9333
+ // field, `onHostKeydown` consumes Escape before it can reach the
9334
+ // dispatcher's listener on `<body>`. This is what would keep the key
9335
+ // stopping here if the panel ever did take focus — the shape `tn-select`
9336
+ // has carried since long before #324.
9337
+ this.overlaySubs.push(this.overlayRef.keydownEvents().subscribe((event) => {
9338
+ if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
9339
+ event.preventDefault();
9340
+ // Latched, so neither the re-open effect nor a `dataSource` response
9341
+ // still in flight can undo the dismissal. See {@link closedByUser}.
9342
+ this.closedByUser = true;
9343
+ this.close();
9344
+ }
9345
+ }));
9233
9346
  }
9234
9347
  detachOverlay() {
9235
9348
  this.overlaySubs.forEach((sub) => sub.unsubscribe());
@@ -9238,7 +9351,7 @@ class TnChipInputComponent {
9238
9351
  this.overlayRef = undefined;
9239
9352
  }
9240
9353
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnChipInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9241
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnChipInputComponent, isStandalone: true, selector: "tn-chip-input", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, separatorKeys: { classPropertyName: "separatorKeys", publicName: "separatorKeys", isSignal: true, isRequired: false, transformFunction: null }, addOnBlur: { classPropertyName: "addOnBlur", publicName: "addOnBlur", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, maxChips: { classPropertyName: "maxChips", publicName: "maxChips", isSignal: true, isRequired: false, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, dataSourceDebounce: { classPropertyName: "dataSourceDebounce", publicName: "dataSourceDebounce", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chipAdded: "chipAdded", chipRemoved: "chipRemoved", searchChange: "searchChange", dataSourceError: "dataSourceError" }, providers: [
9354
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnChipInputComponent, isStandalone: true, selector: "tn-chip-input", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, separatorKeys: { classPropertyName: "separatorKeys", publicName: "separatorKeys", isSignal: true, isRequired: false, transformFunction: null }, addOnBlur: { classPropertyName: "addOnBlur", publicName: "addOnBlur", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, maxChips: { classPropertyName: "maxChips", publicName: "maxChips", isSignal: true, isRequired: false, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, dataSourceDebounce: { classPropertyName: "dataSourceDebounce", publicName: "dataSourceDebounce", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chipAdded: "chipAdded", chipRemoved: "chipRemoved", searchChange: "searchChange", dataSourceError: "dataSourceError" }, host: { listeners: { "keydown": "onHostKeydown($event)" } }, providers: [
9242
9355
  {
9243
9356
  provide: NG_VALUE_ACCESSOR,
9244
9357
  useExisting: forwardRef(() => TnChipInputComponent),
@@ -9254,7 +9367,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
9254
9367
  useExisting: forwardRef(() => TnChipInputComponent),
9255
9368
  multi: true,
9256
9369
  },
9257
- ], template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"resolvedTestId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\"\n />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div class=\"tn-chip-input__dropdown\">\n <!-- Per ARIA a listbox may only contain option children, so the loading row\n lives OUTSIDE it as a live-region sibling. The listbox is also the\n scrollport, which keeps that row pinned below the rows it describes\n rather than scrolling away with them. -->\n <div\n class=\"tn-chip-input__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n [attr.aria-busy]=\"loading() ? true : null\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"suggestionTestId(suggestion)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"onSuggestionMousedown($event)\"\n (click)=\"onSuggestionClick(suggestion)\"\n >\n {{ suggestion.label }}\n </div>\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div\n class=\"tn-chip-input__loading\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"statusTestIdParts('loading')\"\n >\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{display:flex;flex-direction:column;max-height:var(--tn-chip-input-panel-max-height, 320px);background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__listbox{max-height:inherit;overflow-y:auto;min-height:0}.tn-chip-input__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"] }]
9370
+ ], host: {
9371
+ '(keydown)': 'onHostKeydown($event)',
9372
+ }, template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"resolvedTestId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\"\n />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div class=\"tn-chip-input__dropdown\">\n <!-- Per ARIA a listbox may only contain option children, so the loading row\n lives OUTSIDE it as a live-region sibling. The listbox is also the\n scrollport, which keeps that row pinned below the rows it describes\n rather than scrolling away with them. -->\n <div\n class=\"tn-chip-input__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n [attr.aria-busy]=\"loading() ? true : null\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"suggestionTestId(suggestion)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"onSuggestionMousedown($event)\"\n (click)=\"onSuggestionClick(suggestion)\"\n >\n {{ suggestion.label }}\n </div>\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div\n class=\"tn-chip-input__loading\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"statusTestIdParts('loading')\"\n >\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{display:flex;flex-direction:column;max-height:var(--tn-chip-input-panel-max-height, 320px);background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__listbox{max-height:inherit;overflow-y:auto;min-height:0}.tn-chip-input__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"] }]
9258
9373
  }], ctorParameters: () => [], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], separatorKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorKeys", required: false }] }], addOnBlur: [{ type: i0.Input, args: [{ isSignal: true, alias: "addOnBlur", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], maxChips: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxChips", required: false }] }], suggestions: [{ type: i0.Input, args: [{ isSignal: true, alias: "suggestions", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], dataSource: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSource", required: false }] }], dataSourceDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSourceDebounce", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", required: false }] }], chipAdded: [{ type: i0.Output, args: ["chipAdded"] }], chipRemoved: [{ type: i0.Output, args: ["chipRemoved"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], dataSourceError: [{ type: i0.Output, args: ["dataSourceError"] }], container: [{ type: i0.ViewChild, args: ['container', { isSignal: true }] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
9259
9374
 
9260
9375
  /**
@@ -9891,6 +10006,7 @@ class TnMenuComponent {
9891
10006
  contextMenuTemplate = viewChild.required('contextMenuTemplate');
9892
10007
  contextOverlayRef;
9893
10008
  contextBackdropSub;
10009
+ contextKeydownSub;
9894
10010
  overlay = inject(Overlay);
9895
10011
  viewContainerRef = inject(ViewContainerRef);
9896
10012
  onMenuItemClick(item) {
@@ -9967,12 +10083,43 @@ class TnMenuComponent {
9967
10083
  this.contextBackdropSub = this.contextOverlayRef.backdropClick().subscribe(() => {
9968
10084
  this.closeContextMenu();
9969
10085
  });
10086
+ // Escape, which this menu had no handling for at all. CDK's keyboard
10087
+ // dispatcher hands the key to the top-most attached overlay that HAS
10088
+ // subscribers and stops there, so subscribing is both what dismisses this
10089
+ // menu and what keeps the key from reaching a `tn-side-panel` or dialog
10090
+ // underneath it (#324).
10091
+ //
10092
+ // Focus is NOT restored here, unlike `TnMenuTriggerDirective`: a context
10093
+ // menu is opened by pointer at a cursor position with no trigger element
10094
+ // to go back to, which is also why the backdrop-click path above does not
10095
+ // restore it either.
10096
+ //
10097
+ // The dispatcher is the ONLY route here, where the form controls fixed
10098
+ // under #324 also consume Escape on their own host element. They can:
10099
+ // opening one leaves focus on a trigger inside that host, so a host
10100
+ // listener sees the key even when an ancestor would stop it before the
10101
+ // dispatcher's listener on `<body>` runs. A context menu has no such
10102
+ // element — a right-click moves focus nowhere, so Escape starts wherever
10103
+ // focus already was, which need not be inside this `tn-menu` at all.
10104
+ // Consequence, and it is a real gap: inside a `tn-drawer` in `over` mode,
10105
+ // whose panel calls `stopPropagation()` on Escape, the drawer closes and
10106
+ // this menu stays up. Closing it there needs focus management this menu
10107
+ // has never had (see the note on restoration above), not another
10108
+ // listener.
10109
+ this.contextKeydownSub = this.contextOverlayRef.keydownEvents().subscribe((event) => {
10110
+ if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
10111
+ event.preventDefault();
10112
+ this.closeContextMenu();
10113
+ }
10114
+ });
9970
10115
  this.onMenuOpen();
9971
10116
  }
9972
10117
  }
9973
10118
  closeContextMenu() {
9974
10119
  this.contextBackdropSub?.unsubscribe();
9975
10120
  this.contextBackdropSub = undefined;
10121
+ this.contextKeydownSub?.unsubscribe();
10122
+ this.contextKeydownSub = undefined;
9976
10123
  if (this.contextOverlayRef) {
9977
10124
  this.contextOverlayRef.dispose();
9978
10125
  this.contextOverlayRef = undefined;
@@ -9983,6 +10130,8 @@ class TnMenuComponent {
9983
10130
  // Component destroyed while context menu open → clean up without notifying.
9984
10131
  this.contextBackdropSub?.unsubscribe();
9985
10132
  this.contextBackdropSub = undefined;
10133
+ this.contextKeydownSub?.unsubscribe();
10134
+ this.contextKeydownSub = undefined;
9986
10135
  this.contextOverlayRef?.dispose();
9987
10136
  this.contextOverlayRef = undefined;
9988
10137
  }
@@ -15085,8 +15234,15 @@ class TnSelectComponent {
15085
15234
  // the trigger while open also hits the backdrop — it closes here and the
15086
15235
  // trigger's own click never fires, so there's no reopen race.
15087
15236
  this.overlaySubs.push(this.overlayRef.backdropClick().subscribe(() => this.closeDropdown(false)));
15088
- // Escape as a fallback (the trigger keydown handler covers the common case,
15089
- // but if focus ever moves into the panel, this catches it too).
15237
+ // Escape reaching the panel through CDK's keyboard dispatcher, which hands
15238
+ // the key to the top-most attached overlay that HAS subscribers and stops
15239
+ // there — so an overlay that subscribes is also one the key stops at,
15240
+ // rather than carrying on to a `tn-side-panel` underneath it (#324).
15241
+ //
15242
+ // Not reachable from the trigger, which is where focus stays: `onHostKeydown`
15243
+ // consumes Escape before the dispatcher's listener on `<body>` sees it. This
15244
+ // is the fallback for focus that reaches the panel some other way, and it
15245
+ // predates #324.
15090
15246
  this.overlaySubs.push(this.overlayRef.keydownEvents().subscribe((event) => {
15091
15247
  if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
15092
15248
  event.preventDefault();
@@ -15339,7 +15495,9 @@ class TnSelectComponent {
15339
15495
  * - **Enter / Space** opens the dropdown if closed; if open and an option
15340
15496
  * is highlighted, selects that option (in single mode) or toggles it
15341
15497
  * (in multiple mode).
15342
- * - **Escape** closes the dropdown without changing the selection.
15498
+ * - **Escape** is NOT here: it closes the dropdown without changing the
15499
+ * selection, from `onHostKeydown` on this component's host element,
15500
+ * because it also has to consume the key.
15343
15501
  *
15344
15502
  * All navigation keys call `event.preventDefault()` so the page does not
15345
15503
  * scroll while the user is moving through options.
@@ -15392,12 +15550,8 @@ class TnSelectComponent {
15392
15550
  this.activateFocusedOption();
15393
15551
  }
15394
15552
  break;
15395
- case 'Escape':
15396
- if (this.isOpen()) {
15397
- event.preventDefault();
15398
- this.closeDropdown();
15399
- }
15400
- break;
15553
+ // Escape is not here: it is handled on this component's HOST element
15554
+ // instead, because it has to consume the key — see `onHostKeydown`.
15401
15555
  case 'Tab':
15402
15556
  // Standard combobox: Tab moves focus out of the select; close first
15403
15557
  // so the trigger stays a clean tab stop. Don't refocus — that would
@@ -15408,6 +15562,33 @@ class TnSelectComponent {
15408
15562
  break;
15409
15563
  }
15410
15564
  }
15565
+ /**
15566
+ * Escape, which closes the dropdown and CONSUMES the key.
15567
+ *
15568
+ * Consuming matters because of what is usually underneath: since #322 a
15569
+ * `tn-side-panel` is a CDK overlay listening for Escape through
15570
+ * `OverlayKeyboardDispatcher`, which delivers to the top-most attached
15571
+ * overlay that has subscribers. Closing the dropdown disposes this select's
15572
+ * overlay mid-keystroke, taking it out of that stack — so an Escape left to
15573
+ * carry on reached `<body>`, found the panel as the new top-most subscriber,
15574
+ * and closed the whole form along with the dropdown (#324). A `tn-drawer` in
15575
+ * `over` mode has a keydown handler of its own and closed the same way.
15576
+ *
15577
+ * Why this sits on the HOST rather than beside the other keys on the trigger:
15578
+ * `stopPropagation()` stops ANCESTOR listeners, not other listeners on the
15579
+ * same element. Called from the trigger it also hid Escape from a `tnTooltip`
15580
+ * on this component, which dismisses a visible tooltip from a host listener —
15581
+ * so the tooltip stayed up. From here, that directive still sees the key and
15582
+ * only the containers ABOVE this select stop seeing it.
15583
+ */
15584
+ onHostKeydown(event) {
15585
+ if (event.key !== 'Escape' || !this.isOpen()) {
15586
+ return;
15587
+ }
15588
+ event.preventDefault();
15589
+ event.stopPropagation();
15590
+ this.closeDropdown();
15591
+ }
15411
15592
  moveFocus(target) {
15412
15593
  const count = this.navigableOptions().length;
15413
15594
  if (count === 0) {
@@ -15475,7 +15656,7 @@ class TnSelectComponent {
15475
15656
  });
15476
15657
  }
15477
15658
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
15478
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnSelectComponent, isStandalone: true, selector: "tn-select", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, optionGroups: { classPropertyName: "optionGroups", publicName: "optionGroups", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, noOptionsLabel: { classPropertyName: "noOptionsLabel", publicName: "noOptionsLabel", isSignal: true, isRequired: false, transformFunction: null }, allowEmpty: { classPropertyName: "allowEmpty", publicName: "allowEmpty", isSignal: true, isRequired: false, transformFunction: null }, emptyLabel: { classPropertyName: "emptyLabel", publicName: "emptyLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, showSelectAll: { classPropertyName: "showSelectAll", publicName: "showSelectAll", isSignal: true, isRequired: false, transformFunction: null }, selectAllLabel: { classPropertyName: "selectAllLabel", publicName: "selectAllLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", multiSelectionChange: "multiSelectionChange" }, providers: [
15659
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnSelectComponent, isStandalone: true, selector: "tn-select", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, optionGroups: { classPropertyName: "optionGroups", publicName: "optionGroups", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, noOptionsLabel: { classPropertyName: "noOptionsLabel", publicName: "noOptionsLabel", isSignal: true, isRequired: false, transformFunction: null }, allowEmpty: { classPropertyName: "allowEmpty", publicName: "allowEmpty", isSignal: true, isRequired: false, transformFunction: null }, emptyLabel: { classPropertyName: "emptyLabel", publicName: "emptyLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, showSelectAll: { classPropertyName: "showSelectAll", publicName: "showSelectAll", isSignal: true, isRequired: false, transformFunction: null }, selectAllLabel: { classPropertyName: "selectAllLabel", publicName: "selectAllLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", multiSelectionChange: "multiSelectionChange" }, host: { listeners: { "keydown": "onHostKeydown($event)" } }, providers: [
15479
15660
  {
15480
15661
  provide: NG_VALUE_ACCESSOR,
15481
15662
  useExisting: forwardRef(() => TnSelectComponent),
@@ -15491,7 +15672,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
15491
15672
  useExisting: forwardRef(() => TnSelectComponent),
15492
15673
  multi: true
15493
15674
  }
15494
- ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"tn-select-container\">\n <!-- Select Trigger -->\n <div\n #triggerEl\n class=\"tn-select-trigger\"\n role=\"combobox\"\n tnTestIdType=\"select\"\n [tnTestId]=\"resolvedTestId()\"\n [attr.tabindex]=\"isDisabled() ? -1 : 0\"\n [class.disabled]=\"isDisabled()\"\n [class.open]=\"isOpen()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? 'tn-select-dropdown-' + idNamespace() : null\"\n [attr.aria-label]=\"triggerAriaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-disabled]=\"isDisabled()\"\n [attr.aria-activedescendant]=\"isOpen() ? focusedOptionId() : null\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Display Text -->\n <span\n class=\"tn-select-text\"\n [class.placeholder]=\"multiple() ? selectedValues().length === 0 : (selectedValue() === null || selectedValue() === undefined)\">\n {{ displayText() }}\n </span>\n\n <!-- Dropdown Arrow -->\n <div class=\"tn-select-arrow\" [class.open]=\"isOpen()\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"6,9 12,15 18,9\" />\n </svg>\n </div>\n </div>\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. The trigger keeps DOM focus throughout (combobox\n pattern); options use aria-activedescendant for virtual focus. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-select-dropdown\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.id]=\"'tn-select-dropdown-' + idNamespace()\">\n\n <!-- Options List -->\n <div class=\"tn-select-options\">\n <!-- Select-all row (multiple mode with showSelectAll). Toggles every\n selectable option; its checkbox reflects all/some/none. -->\n @if (showSelectAllRow()) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option tn-select-select-all\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"selectAllTestIdParts()\"\n [attr.id]=\"selectAllId()\"\n [class.selected]=\"allSelected()\"\n [class.focused]=\"isSelectAllFocused()\"\n [attr.aria-selected]=\"allSelected()\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"toggleSelectAll()\">\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"allSelected()\"\n [indeterminate]=\"selectAllIndeterminate()\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n {{ resolvedSelectAllLabel() }}\n </div>\n <!-- Decorative only, and deliberately role-less: the panel above is a\n `role=\"listbox\"`, which owns `option` and `group` and nothing else,\n so a `role=\"separator\"` here invalidates the whole listbox\n (aria-required-children, #259). The rule is drawn by its class. -->\n <div class=\"tn-select-separator\"></div>\n }\n\n <!-- Regular Options (preceded by the synthetic empty option when allowEmpty is set) -->\n @for (option of displayOptions(); track $index) {\n <!-- Option activation lives on the trigger via aria-activedescendant; options have tabindex=\"-1\" and never receive DOM focus. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [class.tn-select-empty-option]=\"isEmptyOption(option)\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n\n <!-- Option Groups -->\n @for (group of optionGroups(); track $index; let isFirst = $first) {\n <!-- Group Separator (not shown before first group if we have regular\n options). Role-less for the same reason as the one above the\n options \u2014 see there. -->\n @if (!isFirst || displayOptions().length > 0) {\n <div class=\"tn-select-separator\"></div>\n }\n\n <div role=\"group\" [attr.aria-labelledby]=\"'tn-select-group-' + idNamespace() + '-' + $index\">\n <!-- Group Label -->\n <div\n class=\"tn-select-group-label\"\n [attr.id]=\"'tn-select-group-' + idNamespace() + '-' + $index\"\n [class.disabled]=\"group.disabled\">\n {{ group.label }}\n </div>\n\n <!-- Group Options -->\n @for (option of group.options; track $index) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled || group.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled || group.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option, !!group.disabled)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n </div>\n }\n\n <!-- No Options Message -->\n @if (!hasAnyOptions()) {\n <div class=\"tn-select-no-options\">\n {{ resolvedNoOptionsLabel() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-select-container{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-select-label{display:block;margin-bottom:.5rem;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-select-label.required .required-asterisk{color:var(--tn-error, var(--tn-red, #bd2626));margin-left:.25rem}.tn-select-trigger{position:relative;display:flex;align-items:center;min-height:2.5rem;padding:.5rem 2.5rem .5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:pointer;transition:all .15s ease-in-out;outline:none;box-sizing:border-box}.tn-select-trigger:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-select-trigger:focus-visible{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.error{border-color:var(--tn-error, var(--tn-red, #bd2626))}.tn-select-trigger.error:focus-visible,.tn-select-trigger.error.open{border-color:var(--tn-error, var(--tn-red, #bd2626));box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-error, var(--tn-red, #bd2626)) 25%,transparent)}.tn-select-trigger.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tn-fg1, #212529)}.tn-select-text.placeholder{color:var(--tn-alt-fg1, #999)}.tn-select-arrow{position:absolute;right:.75rem;top:50%;transform:translateY(-50%);color:var(--tn-fg2, #6c757d);transition:transform .15s ease-in-out;pointer-events:none}.tn-select-arrow.open{transform:translateY(-50%) rotate(180deg)}.tn-select-arrow svg{display:block}.tn-select-dropdown{--tn-select-dropdown-max-height: 350px;background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-select-dropdown-max-height);overflow-y:auto}.tn-select-options{padding:.25rem 0}.tn-select-option{display:flex;align-items:center;padding:.5rem .75rem;overflow-wrap:anywhere;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out;pointer-events:auto;position:relative;z-index:1001}.tn-select-option.selected{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg1, #212529)}.tn-select-option:hover:not(.disabled):not(.focused){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-select-option.focused{background-color:var(--tn-alt-bg2, #e8f4fd);box-shadow:inset 2px 0 0 var(--tn-primary, #007bff)}.tn-select-option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-option.tn-select-empty-option{color:var(--tn-fg2, #6c757d)}.tn-select-select-all{font-weight:600}.tn-select-check{margin-right:.5rem;flex-shrink:0;pointer-events:none}.tn-select-separator{height:1px;background:var(--tn-lines, #e0e0e0);margin:.25rem 0}.tn-select-group-label{padding:.375rem .75rem;font-size:.75rem;font-weight:600;color:var(--tn-alt-fg1, #9ca3af);text-transform:uppercase;letter-spacing:.05em;cursor:default;-webkit-user-select:none;user-select:none}.tn-select-group-label.disabled{opacity:.6}.tn-select-no-options{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}.tn-select-error{margin-top:.25rem;font-size:.75rem;color:var(--tn-error, var(--tn-red, #bd2626))}@media(prefers-reduced-motion:reduce){.tn-select-trigger,.tn-select-option,.tn-select-arrow{transition:none}}@media(prefers-contrast:high){.tn-select-trigger{border-width:2px}.tn-select-option.selected{outline:2px solid var(--tn-fg1, #000);outline-offset:-2px}}\n"] }]
15675
+ ], host: {
15676
+ '(keydown)': 'onHostKeydown($event)',
15677
+ }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"tn-select-container\">\n <!-- Select Trigger -->\n <div\n #triggerEl\n class=\"tn-select-trigger\"\n role=\"combobox\"\n tnTestIdType=\"select\"\n [tnTestId]=\"resolvedTestId()\"\n [attr.tabindex]=\"isDisabled() ? -1 : 0\"\n [class.disabled]=\"isDisabled()\"\n [class.open]=\"isOpen()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? 'tn-select-dropdown-' + idNamespace() : null\"\n [attr.aria-label]=\"triggerAriaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-disabled]=\"isDisabled()\"\n [attr.aria-activedescendant]=\"isOpen() ? focusedOptionId() : null\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Display Text -->\n <span\n class=\"tn-select-text\"\n [class.placeholder]=\"multiple() ? selectedValues().length === 0 : (selectedValue() === null || selectedValue() === undefined)\">\n {{ displayText() }}\n </span>\n\n <!-- Dropdown Arrow -->\n <div class=\"tn-select-arrow\" [class.open]=\"isOpen()\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"6,9 12,15 18,9\" />\n </svg>\n </div>\n </div>\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. The trigger keeps DOM focus throughout (combobox\n pattern); options use aria-activedescendant for virtual focus. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-select-dropdown\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.id]=\"'tn-select-dropdown-' + idNamespace()\">\n\n <!-- Options List -->\n <div class=\"tn-select-options\">\n <!-- Select-all row (multiple mode with showSelectAll). Toggles every\n selectable option; its checkbox reflects all/some/none. -->\n @if (showSelectAllRow()) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option tn-select-select-all\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"selectAllTestIdParts()\"\n [attr.id]=\"selectAllId()\"\n [class.selected]=\"allSelected()\"\n [class.focused]=\"isSelectAllFocused()\"\n [attr.aria-selected]=\"allSelected()\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"toggleSelectAll()\">\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"allSelected()\"\n [indeterminate]=\"selectAllIndeterminate()\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n {{ resolvedSelectAllLabel() }}\n </div>\n <!-- Decorative only, and deliberately role-less: the panel above is a\n `role=\"listbox\"`, which owns `option` and `group` and nothing else,\n so a `role=\"separator\"` here invalidates the whole listbox\n (aria-required-children, #259). The rule is drawn by its class. -->\n <div class=\"tn-select-separator\"></div>\n }\n\n <!-- Regular Options (preceded by the synthetic empty option when allowEmpty is set) -->\n @for (option of displayOptions(); track $index) {\n <!-- Option activation lives on the trigger via aria-activedescendant; options have tabindex=\"-1\" and never receive DOM focus. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [class.tn-select-empty-option]=\"isEmptyOption(option)\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n\n <!-- Option Groups -->\n @for (group of optionGroups(); track $index; let isFirst = $first) {\n <!-- Group Separator (not shown before first group if we have regular\n options). Role-less for the same reason as the one above the\n options \u2014 see there. -->\n @if (!isFirst || displayOptions().length > 0) {\n <div class=\"tn-select-separator\"></div>\n }\n\n <div role=\"group\" [attr.aria-labelledby]=\"'tn-select-group-' + idNamespace() + '-' + $index\">\n <!-- Group Label -->\n <div\n class=\"tn-select-group-label\"\n [attr.id]=\"'tn-select-group-' + idNamespace() + '-' + $index\"\n [class.disabled]=\"group.disabled\">\n {{ group.label }}\n </div>\n\n <!-- Group Options -->\n @for (option of group.options; track $index) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled || group.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled || group.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option, !!group.disabled)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n </div>\n }\n\n <!-- No Options Message -->\n @if (!hasAnyOptions()) {\n <div class=\"tn-select-no-options\">\n {{ resolvedNoOptionsLabel() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-select-container{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-select-label{display:block;margin-bottom:.5rem;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-select-label.required .required-asterisk{color:var(--tn-error, var(--tn-red, #bd2626));margin-left:.25rem}.tn-select-trigger{position:relative;display:flex;align-items:center;min-height:2.5rem;padding:.5rem 2.5rem .5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:pointer;transition:all .15s ease-in-out;outline:none;box-sizing:border-box}.tn-select-trigger:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-select-trigger:focus-visible{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.error{border-color:var(--tn-error, var(--tn-red, #bd2626))}.tn-select-trigger.error:focus-visible,.tn-select-trigger.error.open{border-color:var(--tn-error, var(--tn-red, #bd2626));box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-error, var(--tn-red, #bd2626)) 25%,transparent)}.tn-select-trigger.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tn-fg1, #212529)}.tn-select-text.placeholder{color:var(--tn-alt-fg1, #999)}.tn-select-arrow{position:absolute;right:.75rem;top:50%;transform:translateY(-50%);color:var(--tn-fg2, #6c757d);transition:transform .15s ease-in-out;pointer-events:none}.tn-select-arrow.open{transform:translateY(-50%) rotate(180deg)}.tn-select-arrow svg{display:block}.tn-select-dropdown{--tn-select-dropdown-max-height: 350px;background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-select-dropdown-max-height);overflow-y:auto}.tn-select-options{padding:.25rem 0}.tn-select-option{display:flex;align-items:center;padding:.5rem .75rem;overflow-wrap:anywhere;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out;pointer-events:auto;position:relative;z-index:1001}.tn-select-option.selected{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg1, #212529)}.tn-select-option:hover:not(.disabled):not(.focused){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-select-option.focused{background-color:var(--tn-alt-bg2, #e8f4fd);box-shadow:inset 2px 0 0 var(--tn-primary, #007bff)}.tn-select-option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-option.tn-select-empty-option{color:var(--tn-fg2, #6c757d)}.tn-select-select-all{font-weight:600}.tn-select-check{margin-right:.5rem;flex-shrink:0;pointer-events:none}.tn-select-separator{height:1px;background:var(--tn-lines, #e0e0e0);margin:.25rem 0}.tn-select-group-label{padding:.375rem .75rem;font-size:.75rem;font-weight:600;color:var(--tn-alt-fg1, #9ca3af);text-transform:uppercase;letter-spacing:.05em;cursor:default;-webkit-user-select:none;user-select:none}.tn-select-group-label.disabled{opacity:.6}.tn-select-no-options{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}.tn-select-error{margin-top:.25rem;font-size:.75rem;color:var(--tn-error, var(--tn-red, #bd2626))}@media(prefers-reduced-motion:reduce){.tn-select-trigger,.tn-select-option,.tn-select-arrow{transition:none}}@media(prefers-contrast:high){.tn-select-trigger{border-width:2px}.tn-select-option.selected{outline:2px solid var(--tn-fg1, #000);outline-offset:-2px}}\n"] }]
15495
15678
  }], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], optionGroups: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionGroups", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], noOptionsLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "noOptionsLabel", required: false }] }], allowEmpty: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowEmpty", required: false }] }], emptyLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], showSelectAll: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSelectAll", required: false }] }], selectAllLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectAllLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], multiSelectionChange: [{ type: i0.Output, args: ["multiSelectionChange"] }], triggerEl: [{ type: i0.ViewChild, args: ['triggerEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
15496
15679
 
15497
15680
  /**
@@ -24159,6 +24342,41 @@ class TnDateInputComponent {
24159
24342
  }
24160
24343
  // Month is the first field
24161
24344
  }
24345
+ /**
24346
+ * Escape, which closes the calendar and CONSUMES the key.
24347
+ *
24348
+ * Opening the calendar does not move focus into it, so the keystroke a user
24349
+ * actually makes starts on a date segment — inside this host, not inside the
24350
+ * overlay. That is the path the `keydownEvents()` subscription in
24351
+ * `createOverlay` cannot serve: CDK's `OverlayKeyboardDispatcher` listens on
24352
+ * `<body>`, so any ancestor that stops the key first means it never runs.
24353
+ * `tn-drawer` in `over` mode is exactly such an ancestor — it calls
24354
+ * `stopPropagation()` on Escape from a handler on its own panel element — so
24355
+ * a calendar left waiting for the dispatcher stayed open while the drawer
24356
+ * closed underneath it (#324).
24357
+ *
24358
+ * Consuming it also keeps a `tn-side-panel` from acting on the same key:
24359
+ * since #322 the panel is a CDK overlay subscribed through that dispatcher,
24360
+ * and closing this calendar disposes its overlay mid-keystroke, leaving the
24361
+ * panel as the top-most subscriber for an Escape allowed to carry on.
24362
+ *
24363
+ * With the calendar closed the key is NOT consumed: there is nothing to
24364
+ * close and it belongs to whatever contains this field.
24365
+ *
24366
+ * Why the HOST rather than the segment inputs: `stopPropagation()` stops
24367
+ * ANCESTOR listeners, not other listeners on the same element. From here a
24368
+ * `tnTooltip` on this component still sees Escape and dismisses itself, and
24369
+ * only the containers ABOVE this field stop seeing it — the same placement
24370
+ * `tn-select` and `tn-autocomplete` use.
24371
+ */
24372
+ onHostKeydown(event) {
24373
+ if (event.key !== 'Escape' || !this.isOpen()) {
24374
+ return;
24375
+ }
24376
+ event.preventDefault();
24377
+ event.stopPropagation();
24378
+ this.close();
24379
+ }
24162
24380
  openDatepicker() {
24163
24381
  if (this.isOpen()) {
24164
24382
  return;
@@ -24235,11 +24453,27 @@ class TnDateInputComponent {
24235
24453
  this.overlayRef.backdropClick().subscribe(() => {
24236
24454
  this.close();
24237
24455
  });
24456
+ // Escape arriving from INSIDE the calendar — a day button the user tabbed
24457
+ // or clicked to. That keydown starts in the overlay, which is not under
24458
+ // this component's host, so `onHostKeydown` never sees it; CDK's keyboard
24459
+ // dispatcher does, and hands the key to the top-most attached overlay that
24460
+ // HAS subscribers, stopping there. Subscribing is what makes this calendar
24461
+ // — rather than a `tn-side-panel` or dialog underneath it — the thing that
24462
+ // Escape closes (#324).
24463
+ //
24464
+ // Escape from the date segments is the other half and is handled on the
24465
+ // host, because an ancestor can stop the key before the dispatcher runs.
24466
+ this.overlayRef.keydownEvents().subscribe((event) => {
24467
+ if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
24468
+ event.preventDefault();
24469
+ this.close();
24470
+ }
24471
+ });
24238
24472
  this.portal = new TemplatePortal(this.calendarTemplate(), this.viewContainerRef);
24239
24473
  this.overlayRef.attach(this.portal);
24240
24474
  }
24241
24475
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnDateInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
24242
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.0", type: TnDateInputComponent, isStandalone: true, selector: "tn-date-input", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, dateFilter: { classPropertyName: "dateFilter", publicName: "dateFilter", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "tn-date-input" }, providers: [
24476
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.0", type: TnDateInputComponent, isStandalone: true, selector: "tn-date-input", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, dateFilter: { classPropertyName: "dateFilter", publicName: "dateFilter", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keydown": "onHostKeydown($event)" }, classAttribute: "tn-date-input" }, providers: [
24243
24477
  {
24244
24478
  provide: NG_VALUE_ACCESSOR,
24245
24479
  useExisting: forwardRef(() => TnDateInputComponent),
@@ -24256,7 +24490,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
24256
24490
  multi: true
24257
24491
  }
24258
24492
  ], host: {
24259
- 'class': 'tn-date-input'
24493
+ 'class': 'tn-date-input',
24494
+ // Escape only, and deliberately HERE rather than beside the segments' own
24495
+ // keydown binding — see `onHostKeydown`.
24496
+ '(keydown)': 'onHostKeydown($event)'
24260
24497
  }, template: "<div class=\"tn-date-input-container\" tnTestIdType=\"date-input\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-input-wrapper\" style=\"padding-right: 40px;\">\n <!-- Date segments MM/DD/YYYY -->\n <div class=\"tn-date-segment-group\">\n <input\n #monthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('month')\"\n (blur)=\"onSegmentBlur('month')\"\n (keydown)=\"onSegmentKeydown($event, 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #dayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('day')\"\n (blur)=\"onSegmentBlur('day')\"\n (keydown)=\"onSegmentKeydown($event, 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #yearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('year')\"\n (blur)=\"onSegmentBlur('year')\"\n (keydown)=\"onSegmentKeydown($event, 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-input-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"false\"\n [selected]=\"value()\"\n (selectedChange)=\"onDateSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-input-container{position:relative;display:flex;align-items:center}.tn-date-input-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-input-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-input-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-input-toggle:disabled{cursor:not-allowed;opacity:.5}.tn-calendar{box-sizing:content-box;width:calc(7 * var(--calendar-cell-size));background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}.tn-calendar ::ng-deep .tn-calendar-content{padding:0}\n"] }]
24261
24498
  }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], dateFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateFilter", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], monthRef: [{ type: i0.ViewChild, args: ['monthInput', { isSignal: true }] }], dayRef: [{ type: i0.ViewChild, args: ['dayInput', { isSignal: true }] }], yearRef: [{ type: i0.ViewChild, args: ['yearInput', { isSignal: true }] }], calendarTemplate: [{ type: i0.ViewChild, args: ['calendarTemplate', { isSignal: true }] }], wrapperEl: [{ type: i0.ViewChild, args: ['wrapper', { isSignal: true }] }] } });
24262
24499
 
@@ -24723,6 +24960,32 @@ class TnDateRangeInputComponent {
24723
24960
  }
24724
24961
  }
24725
24962
  }
24963
+ /**
24964
+ * Escape, which closes the calendar and CONSUMES the key. Same reasoning as
24965
+ * `TnDateInputComponent.onHostKeydown`, which this mirrors: opening does not
24966
+ * move focus into the overlay, so the real keystroke starts on a date
24967
+ * segment inside this host, where an ancestor such as a `tn-drawer` in
24968
+ * `over` mode would stop it before CDK's keyboard dispatcher ever ran — and
24969
+ * a calendar waiting on the dispatcher stayed open while the drawer closed
24970
+ * underneath it (#324).
24971
+ *
24972
+ * Consuming it also keeps a `tn-side-panel` from acting on the same key: the
24973
+ * panel is a CDK overlay subscribed through that dispatcher (#322), and
24974
+ * closing this calendar disposes its overlay mid-keystroke, leaving the
24975
+ * panel as the top-most subscriber for an Escape allowed to carry on.
24976
+ *
24977
+ * Closed, the key is not consumed; it belongs to whatever contains this
24978
+ * field. On the HOST rather than the segments so that `stopPropagation()`
24979
+ * still leaves a `tnTooltip` on this component able to see the key.
24980
+ */
24981
+ onHostKeydown(event) {
24982
+ if (event.key !== 'Escape' || !this.isOpen()) {
24983
+ return;
24984
+ }
24985
+ event.preventDefault();
24986
+ event.stopPropagation();
24987
+ this.close();
24988
+ }
24726
24989
  openDatepicker() {
24727
24990
  if (this.isOpen()) {
24728
24991
  return;
@@ -24799,11 +25062,27 @@ class TnDateRangeInputComponent {
24799
25062
  this.overlayRef.backdropClick().subscribe(() => {
24800
25063
  this.close();
24801
25064
  });
25065
+ // Escape arriving from INSIDE the calendar — a day button the user tabbed
25066
+ // or clicked to. That keydown starts in the overlay, which is not under
25067
+ // this component's host, so `onHostKeydown` never sees it; CDK's keyboard
25068
+ // dispatcher does, and hands the key to the top-most attached overlay that
25069
+ // HAS subscribers, stopping there. Subscribing is what makes this calendar
25070
+ // — rather than a `tn-side-panel` or dialog underneath it — the thing that
25071
+ // Escape closes (#324).
25072
+ //
25073
+ // Escape from the date segments is the other half and is handled on the
25074
+ // host, because an ancestor can stop the key before the dispatcher runs.
25075
+ this.overlayRef.keydownEvents().subscribe((event) => {
25076
+ if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
25077
+ event.preventDefault();
25078
+ this.close();
25079
+ }
25080
+ });
24802
25081
  this.portal = new TemplatePortal(this.calendarTemplate(), this.viewContainerRef);
24803
25082
  this.overlayRef.attach(this.portal);
24804
25083
  }
24805
25084
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnDateRangeInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
24806
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.0", type: TnDateRangeInputComponent, isStandalone: true, selector: "tn-date-range-input", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "tn-date-range-input" }, providers: [
25085
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.0", type: TnDateRangeInputComponent, isStandalone: true, selector: "tn-date-range-input", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keydown": "onHostKeydown($event)" }, classAttribute: "tn-date-range-input" }, providers: [
24807
25086
  {
24808
25087
  provide: NG_VALUE_ACCESSOR,
24809
25088
  useExisting: forwardRef(() => TnDateRangeInputComponent),
@@ -24820,7 +25099,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
24820
25099
  multi: true
24821
25100
  }
24822
25101
  ], host: {
24823
- 'class': 'tn-date-range-input'
25102
+ 'class': 'tn-date-range-input',
25103
+ // Escape only, and deliberately HERE rather than beside the segments' own
25104
+ // keydown binding — see `onHostKeydown`.
25105
+ '(keydown)': 'onHostKeydown($event)'
24824
25106
  }, template: "<div class=\"tn-date-range-container\" tnTestIdType=\"date-range\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-range-wrapper\" style=\"padding-right: 40px;\">\n <!-- Start date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #startMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'month')\"\n (blur)=\"onSegmentBlur('start', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'day')\"\n (blur)=\"onSegmentBlur('start', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'year')\"\n (blur)=\"onSegmentBlur('start', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'year')\">\n </div>\n\n <span class=\"tn-date-range-separator\">\u2013</span>\n\n <!-- End date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #endMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'month')\"\n (blur)=\"onSegmentBlur('end', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'day')\"\n (blur)=\"onSegmentBlur('end', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'year')\"\n (blur)=\"onSegmentBlur('end', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-range-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"true\"\n [selectedRange]=\"value()\"\n (selectedRangeChange)=\"onRangeSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-range-container{position:relative;display:flex;align-items:center}.tn-date-range-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-range-separator{padding:0 .25em;-webkit-user-select:none;user-select:none;color:var(--tn-fg2, #666);flex-shrink:0}.tn-date-range-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-range-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-range-toggle:disabled{cursor:not-allowed;opacity:.5}.tn-calendar{box-sizing:content-box;width:calc(7 * var(--calendar-cell-size));background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}.tn-calendar ::ng-deep .tn-calendar-content{padding:0}\n"] }]
24825
25107
  }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], startMonthRef: [{ type: i0.ViewChild, args: ['startMonthInput', { isSignal: true }] }], startDayRef: [{ type: i0.ViewChild, args: ['startDayInput', { isSignal: true }] }], startYearRef: [{ type: i0.ViewChild, args: ['startYearInput', { isSignal: true }] }], endMonthRef: [{ type: i0.ViewChild, args: ['endMonthInput', { isSignal: true }] }], endDayRef: [{ type: i0.ViewChild, args: ['endDayInput', { isSignal: true }] }], endYearRef: [{ type: i0.ViewChild, args: ['endYearInput', { isSignal: true }] }], calendarTemplate: [{ type: i0.ViewChild, args: ['calendarTemplate', { isSignal: true }] }], wrapperEl: [{ type: i0.ViewChild, args: ['wrapper', { isSignal: true }] }] } });
24826
25108
 
@@ -26982,6 +27264,40 @@ const TN_SIDE_PANEL_CONTENT_LABEL = 'Panel content';
26982
27264
  * why it is copied from the rule at all.
26983
27265
  */
26984
27266
  const TN_SIDE_PANEL_OVERFLOW_TOLERANCE_PX = TN_SCROLLABLE_REGION_TOLERANCE_PX;
27267
+ /**
27268
+ * Marks the overlay as currently hosted in a CDK overlay (#322).
27269
+ *
27270
+ * It is what makes the overlay render at all — `.tn-side-panel__overlay` is
27271
+ * `display: none` without it. A detached overlay is back inside
27272
+ * `<tn-side-panel>`, whose ancestors the consumer owns, and one of them having a
27273
+ * `transform` would make `position: fixed` resolve against that box instead of
27274
+ * the viewport and paint the closed panel next to it. On `<body>`, where the
27275
+ * overlay used to live unconditionally, that could not happen.
27276
+ *
27277
+ * Applied imperatively rather than bound, for the reason
27278
+ * `TN_SIDE_PANEL_OPEN_CLASS` gives — the two have to land in that order and in
27279
+ * that relationship to the attach.
27280
+ */
27281
+ const TN_SIDE_PANEL_ATTACHED_CLASS = 'tn-side-panel__overlay--attached';
27282
+ /**
27283
+ * Puts the panel in its open position, and is the class the open/close
27284
+ * transition runs on.
27285
+ *
27286
+ * WHY THIS IS NOT A TEMPLATE BINDING (#322)
27287
+ * -----------------------------------------
27288
+ * Angular refreshes a component's template BEFORE it runs that component's
27289
+ * effects, so a `[class.…--open]="open()"` binding is written while the overlay
27290
+ * is still detached and `display: none`. The effect below then attaches it, and
27291
+ * the browser's first look at the element has it already open — a transition
27292
+ * needs a previous computed style to run from, and there is none. The panel
27293
+ * appears instead of sliding in.
27294
+ *
27295
+ * So the order is owned here instead: attach, force a layout read so the closed
27296
+ * position is what the browser has computed, then add this. It is still applied
27297
+ * inside the same change detection pass the `open` change arrives in, so a spec
27298
+ * that calls `detectChanges()` and asserts on the class sees it.
27299
+ */
27300
+ const TN_SIDE_PANEL_OPEN_CLASS = 'tn-side-panel__overlay--open';
26985
27301
  /**
26986
27302
  * Directive to mark an element as a side-panel footer action.
26987
27303
  *
@@ -27041,15 +27357,89 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
27041
27357
  * own, focus it yourself once the panel is open; the component leaves focus
27042
27358
  * alone as soon as it is inside the panel. `lib/a11y/initial-focus.ts` holds
27043
27359
  * the reasoning for capturing the container rather than a control.
27360
+ *
27361
+ * WHERE IT RENDERS, AND WHY THAT DECIDES WHAT IT STACKS AGAINST
27362
+ * ------------------------------------------------------------
27363
+ * The panel renders through a CDK `OverlayRef` created WHEN IT OPENS (#322),
27364
+ * which is what makes it stack with `TnDialog`, `tn-menu` and tooltips by open
27365
+ * order: whatever attached last paints on top, in both directions. It used to
27366
+ * append its overlay to `<body>` at construction time with a fixed `z-index`,
27367
+ * and the CDK overlay container is a `<body>` child with the same one — so the
27368
+ * winner was whichever element `<body>` happened to receive last, which follows
27369
+ * nothing a caller can see. A panel opened FROM a dialog landed under that
27370
+ * dialog's backdrop; on a browser with the popover API, where CDK puts its
27371
+ * overlays in the top layer, no `z-index` on a `<body>` child could have won at
27372
+ * all.
27373
+ *
27374
+ * Three things follow from being a CDK overlay, and each replaces something
27375
+ * this component used to do for itself:
27376
+ *
27377
+ * - **Escape reaches the topmost overlay only**, through CDK's
27378
+ * `OverlayKeyboardDispatcher`, rather than through a `keydown` handler on the
27379
+ * panel that stopped propagation to keep a dialog underneath from closing too.
27380
+ * The panel therefore also closes on Escape pressed outside it, which is what
27381
+ * `TnDialog` does — `tn-drawer` still binds `keydown` on its own panel and
27382
+ * does not.
27383
+ * - **A CDK dialog opened over an open panel hides the panel from assistive
27384
+ * technology.** CDK does this by sweeping the overlay container's SIBLINGS,
27385
+ * which no longer includes the panel, so the component tracks it — see
27386
+ * `dialogsAbove`.
27387
+ * - **A consumer no longer re-homes the overlay or overrides its `z-index` and
27388
+ * `pointer-events`.** There is no `z-index` on it any more; the CDK pane it
27389
+ * lives in carries the stacking.
27044
27390
  */
27045
27391
  class TnSidePanelComponent {
27046
27392
  iconRegistry = inject(TnIconRegistryService);
27047
27393
  document = inject(DOCUMENT);
27048
27394
  destroyRef = inject(DestroyRef);
27395
+ injector = inject(Injector);
27396
+ dialog = inject(Dialog);
27049
27397
  overlayRef = viewChild.required('overlay');
27050
27398
  panelRef = viewChild.required('panel');
27051
27399
  contentRef = viewChild.required('content');
27052
27400
  initialized = signal(false, ...(ngDevMode ? [{ debugName: "initialized" }] : []));
27401
+ /**
27402
+ * The CDK overlay currently hosting `overlayRef`'s element, or `null` while
27403
+ * the panel is closed (#322).
27404
+ *
27405
+ * **Created on open and disposed once the close has settled**, not once for
27406
+ * the component's lifetime. That is the whole mechanism: CDK decides stacking
27407
+ * by the order overlays ATTACH — `showPopover()` order in the top layer, and
27408
+ * `_updateStackingOrder()`'s move to the end of the container where the
27409
+ * popover API is missing — so an overlay created when the component was built
27410
+ * would stack by construction order, which for a panel rendered inside a
27411
+ * dialog is exactly backwards.
27412
+ *
27413
+ * Non-null is therefore also the answer to "is this panel currently one of
27414
+ * the overlays on screen", which is what `dialogsAbove` keys off.
27415
+ */
27416
+ cdkOverlay = null;
27417
+ /** Escape handling for the overlay above, dropped with it. */
27418
+ keydowns = null;
27419
+ /**
27420
+ * The CDK dialogs currently covering this panel (#322).
27421
+ *
27422
+ * The panel is hidden from assistive technology while this is non-empty,
27423
+ * which is what CDK's own `Dialog` does for everything outside the overlay
27424
+ * container — it sweeps the container's SIBLINGS, and a panel that now lives
27425
+ * INSIDE the container is not one. A dialog already open when the panel opens
27426
+ * is underneath it and never joins this; only dialogs that arrive afterwards
27427
+ * are above.
27428
+ *
27429
+ * A SET RATHER THAN A COUNT, and each dialog removes ITSELF. Nothing
27430
+ * unsubscribes a dialog's `closed` when the panel closes underneath it — the
27431
+ * panel cannot outlive its own release to do that — so with a count, a
27432
+ * dialog opened during one open and closed during the NEXT one decremented a
27433
+ * tally it had never contributed to, and put the panel back in the
27434
+ * accessibility tree with a live modal still stacked over it. Removing a
27435
+ * member that is not there is a no-op, which is the property a count does
27436
+ * not have.
27437
+ *
27438
+ * Emptied only when the last one goes, on the same reasoning as CDK's
27439
+ * `_removeOpenDialog`: two stacked dialogs closing one at a time must not
27440
+ * un-hide the panel while one is still covering it.
27441
+ */
27442
+ dialogsAbove = signal(new Set(), ...(ngDevMode ? [{ debugName: "dialogsAbove" }] : []));
27053
27443
  /**
27054
27444
  * Whether the content region carries the tab stop, its role and its name
27055
27445
  * (#248) — which is NOT the same question as whether it currently overflows.
@@ -27062,10 +27452,14 @@ class TnSidePanelComponent {
27062
27452
  * answer is held true while the region has focus, and why `role` and
27063
27453
  * `aria-label` are gated on the same signal as `tabindex` rather than left on.
27064
27454
  *
27065
- * Read in `afterNextRender`, which is where the helper takes its first
27066
- * measurement — before the overlay is portaled to `<body>` below, which does
27067
- * not affect it: `.tn-side-panel__overlay` is `position: fixed; inset: 0`, so
27068
- * its size comes from the viewport rather than from its parent.
27455
+ * The helper's FIRST measurement reads a closed panel, which is
27456
+ * `display: none` since #322 and so reads as not overflowing. That is the
27457
+ * right answer for a panel nobody can reach yet, and it does not stick: the
27458
+ * helper's `ResizeObserver` fires when the overlay attaches and the region
27459
+ * gets a box, which is the same instrument that already answered a viewport
27460
+ * resize. Once open, `.tn-side-panel__overlay` is `position: fixed;
27461
+ * inset: 0`, so the region's size comes from the viewport rather than from
27462
+ * whichever CDK pane is hosting it.
27069
27463
  */
27070
27464
  contentKeyboardReachable = tnScrollableRegion(() => this.contentRef().nativeElement);
27071
27465
  // Two-way bindable via [(open)]
@@ -27179,6 +27573,14 @@ class TnSidePanelComponent {
27179
27573
  ariaLabel: this.ariaLabel,
27180
27574
  ariaLabelledby: this.resolvedAriaLabelledby,
27181
27575
  });
27576
+ /**
27577
+ * Whether the overlay is out of the accessibility tree: closed, or covered by
27578
+ * a CDK dialog that opened over it (#322).
27579
+ *
27580
+ * The two are one attribute because they are one question, and rendering them
27581
+ * from separate bindings would mean the second could clear the first.
27582
+ */
27583
+ hiddenFromAssistiveTech = computed(() => !this.open() || this.dialogsAbove().size > 0, ...(ngDevMode ? [{ debugName: "hiddenFromAssistiveTech" }] : []));
27182
27584
  // Focus restoration
27183
27585
  previouslyFocusedElement = null;
27184
27586
  /**
@@ -27186,8 +27588,22 @@ class TnSidePanelComponent {
27186
27588
  * above fire exactly once per change whether or not a transition ran. A field
27187
27589
  * initializer rather than the constructor, because it registers an `effect`
27188
27590
  * and so needs an injection context.
27591
+ *
27592
+ * It is also what times the overlay's release (#322). The CDK overlay has to
27593
+ * outlive the CLOSE — disposing it puts the element back in this component's
27594
+ * view, where it is `display: none`, so doing that the moment `open` goes
27595
+ * false replaces the slide-out with a disappearance. "The close has settled"
27596
+ * is exactly the question this helper already answers, for a transition that
27597
+ * may never fire.
27189
27598
  */
27190
- lifecycle = tnTransitionLifecycle(this.open, (open) => (open ? this.opened.emit() : this.closed.emit()));
27599
+ lifecycle = tnTransitionLifecycle(this.open, (open) => {
27600
+ if (open) {
27601
+ this.opened.emit();
27602
+ return;
27603
+ }
27604
+ this.releaseOverlay();
27605
+ this.closed.emit();
27606
+ });
27191
27607
  constructor() {
27192
27608
  this.registerMdiIcons();
27193
27609
  // Moves focus onto the panel when it opens (#227) — the other half of the
@@ -27213,10 +27629,103 @@ class TnSidePanelComponent {
27213
27629
  this.restoreFocus();
27214
27630
  }
27215
27631
  });
27216
- afterNextRender(() => {
27217
- this.document.body.appendChild(this.overlayRef().nativeElement);
27218
- this.initialized.set(true);
27632
+ // Hosts the overlay in a CDK overlay while the panel is open (#322). The
27633
+ // element it moves is the one this component renders, so the projected
27634
+ // content, the view queries and the bindings on it all survive the move —
27635
+ // which a `TemplatePortal` would not give, since detaching one destroys the
27636
+ // view and takes the caller's content with it.
27637
+ effect(() => {
27638
+ if (this.open()) {
27639
+ this.showOverlay();
27640
+ }
27641
+ else {
27642
+ this.overlayRef().nativeElement.classList.remove(TN_SIDE_PANEL_OPEN_CLASS);
27643
+ }
27644
+ });
27645
+ // A dialog opened while this panel is on screen is above it, because CDK
27646
+ // stacks by attach order — so it covers the panel, and the panel leaves the
27647
+ // accessibility tree for as long as it does. Dialogs already open when the
27648
+ // panel opens are underneath it and are not counted: `cdkOverlay` is null
27649
+ // until the panel attaches.
27650
+ this.dialog.afterOpened.pipe(takeUntilDestroyed()).subscribe((ref) => {
27651
+ if (!this.cdkOverlay) {
27652
+ return;
27653
+ }
27654
+ this.dialogsAbove.update((above) => new Set(above).add(ref));
27655
+ ref.closed.pipe(take$1(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
27656
+ this.dialogsAbove.update((above) => {
27657
+ const remaining = new Set(above);
27658
+ remaining.delete(ref);
27659
+ return remaining;
27660
+ });
27661
+ });
27219
27662
  });
27663
+ afterNextRender(() => this.initialized.set(true));
27664
+ }
27665
+ /**
27666
+ * Put the overlay on screen, in a CDK overlay created now (#322).
27667
+ *
27668
+ * The three steps are ordered, and the order is the reason this is not two
27669
+ * bindings:
27670
+ *
27671
+ * 1. **Attach**, which is what fixes where the panel sits in the stack.
27672
+ * 2. **Read layout**, which forces the browser to compute the panel's closed
27673
+ * position in its new home. Without it the attach and the open class land
27674
+ * in one style update, the browser has no "before" to transition from, and
27675
+ * the panel appears rather than slides. A deliberate synchronous reflow,
27676
+ * and the only one: it happens once per open.
27677
+ * 3. **Open**, which is the change the transition runs on.
27678
+ *
27679
+ * Re-entrant on purpose. A panel reopened while its close is still animating
27680
+ * keeps the overlay it already has — the stack has not changed under it, and
27681
+ * `tnTransitionLifecycle` has already cancelled the close that would have
27682
+ * released it.
27683
+ */
27684
+ showOverlay() {
27685
+ const element = this.overlayRef().nativeElement;
27686
+ if (!this.cdkOverlay) {
27687
+ this.cdkOverlay = createOverlayRef(this.injector, {
27688
+ positionStrategy: createGlobalPositionStrategy(this.injector),
27689
+ // The panel is `position: fixed` and fills the viewport, so there is
27690
+ // nothing to reposition or block when the page scrolls.
27691
+ scrollStrategy: createNoopScrollStrategy(),
27692
+ // The panel draws its own backdrop inside the overlay, because it is
27693
+ // the thing that fades with the panel.
27694
+ hasBackdrop: false,
27695
+ // Names the pane, the same way `TnDialog` names its own
27696
+ // `tn-dialog-panel` — it is how anything looking at the overlay
27697
+ // container tells one of these apart from a dialog or a menu.
27698
+ panelClass: 'tn-side-panel-pane',
27699
+ });
27700
+ this.cdkOverlay.attach(new DomPortal(element));
27701
+ element.classList.add(TN_SIDE_PANEL_ATTACHED_CLASS);
27702
+ // Escape, from CDK's keyboard dispatcher, which delivers it to the
27703
+ // TOPMOST attached overlay and nothing else. That is what keeps a dialog
27704
+ // underneath this panel from closing on the same keystroke, and it works
27705
+ // in the other direction too — a dialog raised from the panel takes the
27706
+ // key instead, which a handler on this element could not have known.
27707
+ this.keydowns = this.cdkOverlay
27708
+ .keydownEvents()
27709
+ .subscribe((event) => this.onOverlayKeydown(event));
27710
+ }
27711
+ element.getBoundingClientRect();
27712
+ element.classList.add(TN_SIDE_PANEL_OPEN_CLASS);
27713
+ }
27714
+ /**
27715
+ * Give the CDK overlay back, once the close has finished animating.
27716
+ *
27717
+ * Disposing rather than detaching, so that the next open builds a new overlay
27718
+ * and takes a new place in the stack — see `cdkOverlay`. It restores the
27719
+ * element to this component's own view, where `display: none` keeps it out of
27720
+ * the way until then.
27721
+ */
27722
+ releaseOverlay() {
27723
+ this.keydowns?.unsubscribe();
27724
+ this.keydowns = null;
27725
+ this.cdkOverlay?.dispose();
27726
+ this.cdkOverlay = null;
27727
+ this.dialogsAbove.set(new Set());
27728
+ this.overlayRef().nativeElement.classList.remove(TN_SIDE_PANEL_ATTACHED_CLASS);
27220
27729
  }
27221
27730
  ngOnDestroy() {
27222
27731
  const overlay = this.overlayRef().nativeElement;
@@ -27233,6 +27742,11 @@ class TnSidePanelComponent {
27233
27742
  // in and back to a trigger they left minutes ago. A no-op after an
27234
27743
  // ordinary close either way, which clears `previouslyFocusedElement`.
27235
27744
  const heldFocus = overlay.contains(this.document.activeElement);
27745
+ // Released BEFORE the removal, and unconditionally: a panel destroyed while
27746
+ // open still holds a CDK overlay, and disposing it is what takes the pane
27747
+ // out of the overlay container. The element itself comes back here first,
27748
+ // which is what `remove()` below then takes out of the document.
27749
+ this.releaseOverlay();
27236
27750
  overlay.remove();
27237
27751
  if (heldFocus) {
27238
27752
  this.restoreFocus();
@@ -27257,9 +27771,21 @@ class TnSidePanelComponent {
27257
27771
  this.dismiss();
27258
27772
  }
27259
27773
  }
27260
- onKeydown(event) {
27774
+ /**
27775
+ * Escape, delivered by CDK's `OverlayKeyboardDispatcher` (#322).
27776
+ *
27777
+ * It arrives only while this panel is the topmost attached overlay, so
27778
+ * nothing here has to decide whether the key was meant for something above or
27779
+ * below — which is what the `stopPropagation` this replaced was standing in
27780
+ * for, and it could only ever guard the direction it knew about.
27781
+ *
27782
+ * `preventDefault` rather than `stopPropagation`, matching CDK's own
27783
+ * `DialogRef`: the key has already been routed, and swallowing it would hide
27784
+ * it from a consumer listening at the document for their own reasons.
27785
+ */
27786
+ onOverlayKeydown(event) {
27261
27787
  if (event.key === 'Escape' && this.closeOnEscape() && this.open()) {
27262
- event.stopPropagation();
27788
+ event.preventDefault();
27263
27789
  this.dismiss();
27264
27790
  }
27265
27791
  }
@@ -27295,14 +27821,14 @@ class TnSidePanelComponent {
27295
27821
  });
27296
27822
  }
27297
27823
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSidePanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
27298
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnSidePanelComponent, isStandalone: true, selector: "tn-side-panel", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, hasBackdrop: { classPropertyName: "hasBackdrop", publicName: "hasBackdrop", isSignal: true, isRequired: false, transformFunction: null }, closeOnBackdropClick: { classPropertyName: "closeOnBackdropClick", publicName: "closeOnBackdropClick", isSignal: true, isRequired: false, transformFunction: null }, closeOnEscape: { classPropertyName: "closeOnEscape", publicName: "closeOnEscape", isSignal: true, isRequired: false, transformFunction: null }, closeGuard: { classPropertyName: "closeGuard", publicName: "closeGuard", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, closeButtonTestId: { classPropertyName: "closeButtonTestId", publicName: "closeButtonTestId", isSignal: true, isRequired: false, transformFunction: null }, closeButtonAriaLabel: { classPropertyName: "closeButtonAriaLabel", publicName: "closeButtonAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, contentAriaLabel: { classPropertyName: "contentAriaLabel", publicName: "contentAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "ariaLabelledby", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", opened: "opened", closed: "closed" }, host: { properties: { "attr.data-tn-panel": "panelId" }, classAttribute: "tn-side-panel" }, queries: [{ propertyName: "actionContent", predicate: TnSidePanelActionDirective, isSignal: true }], viewQueries: [{ propertyName: "overlayRef", first: true, predicate: ["overlay"], descendants: true, isSignal: true }, { propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true, isSignal: true }, { propertyName: "contentRef", first: true, predicate: ["content"], descendants: true, isSignal: true }], ngImport: i0, template: "<!--\n Overlay wrapper: portaled to document.body.\n\n A CLOSED panel is `inert` as well as `aria-hidden` (#214). It stays in the DOM\n and keeps its close button and its projected content, so `aria-hidden` alone\n hid a subtree a keyboard user could still Tab into \u2014 landing focus on controls\n that are translated off-screen. `inert` takes the subtree out of the focus\n order and the accessibility tree together; `aria-hidden` stays beside it for\n browsers without `inert` (Firefox before 112), where it is still the half that\n works. Same reasoning as the checkbox in `list-option.component.html`.\n\n `aria-label` and `aria-labelledby` are gated on `open()` for the same reason\n `aria-modal` is: a closed panel is not in the accessibility tree, so a name on\n it would describe nothing. `resolvedAriaLabelledby` prefers the visible\n heading. `resolvedAriaLabel` is `null` when the panel is named by a heading or\n an IDREF and the caller passed no `ariaLabel` of their own \u2014 an explicit one is\n always emitted, so that a dangling IDREF cannot leave the panel unnamed.\n-->\n<div\n #overlay\n class=\"tn-side-panel__overlay\"\n role=\"dialog\"\n tnTestIdType=\"side-panel\"\n [attr.data-tn-panel]=\"panelId\"\n [tnTestId]=\"testId()\"\n [class.tn-side-panel__overlay--initialized]=\"initialized()\"\n [class.tn-side-panel__overlay--open]=\"open()\"\n [attr.aria-modal]=\"open() ? 'true' : null\"\n [attr.aria-labelledby]=\"open() ? resolvedAriaLabelledby() : null\"\n [attr.aria-label]=\"open() ? resolvedAriaLabel() : null\"\n [attr.aria-hidden]=\"!open() ? 'true' : null\"\n [attr.inert]=\"open() ? null : ''\">\n\n <!-- Backdrop -->\n @if (hasBackdrop()) {\n <div\n class=\"tn-side-panel__backdrop\"\n aria-hidden=\"true\"\n (click)=\"onBackdropClick()\">\n </div>\n }\n\n <!--\n Panel. `cdkTrapFocus` keeps Tab inside it while it is open; what moves focus\n IN on open is `tnFocusOnOpen` in the component, not\n `[cdkTrapFocusAutoCapture]`, which used to be here and could silently do\n nothing (#227). `tabindex=\"-1\"` is what makes this element the target it\n focuses \u2014 see `../a11y/initial-focus.ts` for why the container and not the\n first control inside it.\n -->\n <div\n #panel\n class=\"tn-side-panel__panel\"\n tabindex=\"-1\"\n cdkTrapFocus\n [style.width]=\"width()\"\n (transitionend)=\"onTransitionEnd($event)\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Header -->\n <header class=\"tn-side-panel__header\">\n <!--\n No heading element at all when there is no title, rather than an empty\n one: an `<h2>` with no text is an `empty-heading` violation, and it puts\n a level-2 heading with nothing in it into the document outline.\n -->\n @if (hasTitle()) {\n <h2 class=\"tn-side-panel__title\" [id]=\"titleId\">{{ title() }}</h2>\n }\n <div class=\"tn-side-panel__header-actions\">\n <ng-content select=\"[tnSidePanelHeaderAction]\" />\n <tn-icon-button\n name=\"close\"\n library=\"mdi\"\n size=\"sm\"\n [ariaLabel]=\"closeButtonAriaLabel()\"\n [testId]=\"closeButtonTestId()\"\n (onClick)=\"dismiss()\" />\n </div>\n </header>\n\n <!--\n Content. This is the element that scrolls, and a scrolling element a\n keyboard cannot reach is content below the fold that a keyboard cannot\n read (#248). The panel's own `tabindex=\"-1\"` above does not cover it: that\n is a focus TARGET, not a tab stop, and it is on the container rather than\n on the element with the overflow.\n\n All three attributes are gated together, and the gate is the point \u2014 an\n unconditional tab stop would land on every panel in the library, including\n the ones whose content fits, where it announces a group and does nothing.\n\n The gate is `contentKeyboardReachable`, which is the measurement OR the\n region's own focus, not the measurement alone. Content that stops\n overflowing while the region is focused would otherwise take the tab stop\n off the focused element and blur a keyboard user out of the open panel.\n Both halves are `tnScrollableRegion`'s, in `../a11y/scrollable-region.ts`,\n which says why in full \u2014 including why it listens for this element's own\n `focus`/`blur` itself rather than leaving each caller to bind them.\n\n `role=\"group\"` rather than the `region` a named `<section>` would imply:\n `region` is a landmark, and a landmark named the same thing in every panel\n is what `landmark-unique` reports the moment two of them are open. A group\n is not summarised, which is what this is.\n -->\n <section\n #content\n class=\"tn-side-panel__content\"\n [attr.tabindex]=\"contentKeyboardReachable() ? '0' : null\"\n [attr.role]=\"contentKeyboardReachable() ? 'group' : null\"\n [attr.aria-label]=\"contentKeyboardReachable() ? contentAriaLabel() : null\">\n <ng-content />\n </section>\n\n <!-- Actions -->\n @if (hasActions()) {\n <footer class=\"tn-side-panel__actions\">\n <ng-content select=\"[tnSidePanelAction]\" />\n </footer>\n }\n </div>\n</div>\n", styles: [":host{display:contents}.tn-side-panel__overlay{position:fixed;inset:0;z-index:1000;pointer-events:none}.tn-side-panel__overlay--open{pointer-events:auto}.tn-side-panel__overlay--open .tn-side-panel__backdrop{opacity:1}.tn-side-panel__overlay--open .tn-side-panel__panel{transform:translate(0);box-shadow:-4px 0 24px #0000004d}.tn-side-panel__backdrop{position:absolute;inset:0;background:#00000080;opacity:0}.tn-side-panel__overlay--initialized .tn-side-panel__backdrop{transition:opacity .2s ease}.tn-side-panel__panel{position:absolute;top:0;right:0;bottom:0;display:flex;flex-direction:column;max-width:100vw;background:var(--tn-bg2, #282828);color:var(--tn-fg1, #ffffff);box-shadow:-4px 0 24px #0000;outline:none;transform:translate(100%)}.tn-side-panel__overlay--initialized .tn-side-panel__panel{transition:transform .3s cubic-bezier(.4,0,.2,1),box-shadow .3s cubic-bezier(.4,0,.2,1)}.tn-side-panel__header{flex:0 0 auto;display:flex;align-items:center;gap:16px;padding:16px 24px;border-bottom:1px solid var(--tn-lines, #383838)}.tn-side-panel__title{margin:0;font-size:1.25rem;font-weight:600;line-height:1.5;color:var(--tn-fg1, #ffffff);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tn-side-panel__header-actions{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto}.tn-side-panel__content{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;padding:var(--tn-content-padding, 24px);-webkit-overflow-scrolling:touch;overflow-wrap:break-word}.tn-side-panel__actions{flex:0 0 auto;display:flex;justify-content:flex-end;gap:12px;padding:16px 24px;border-top:1px solid var(--tn-lines, #383838)}@media(max-width:640px){.tn-side-panel__panel{width:100vw!important}}@media(prefers-reduced-motion:reduce){.tn-side-panel__panel,.tn-side-panel__backdrop{transition-duration:0ms!important}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: i1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: TnIconButtonComponent, selector: "tn-icon-button", inputs: ["disabled", "dense", "ariaLabel", "ariaExpanded", "testId", "name", "size", "color", "tooltip", "tooltipPosition", "tooltipSticky", "library", "iconClass"], outputs: ["onClick"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
27824
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnSidePanelComponent, isStandalone: true, selector: "tn-side-panel", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, hasBackdrop: { classPropertyName: "hasBackdrop", publicName: "hasBackdrop", isSignal: true, isRequired: false, transformFunction: null }, closeOnBackdropClick: { classPropertyName: "closeOnBackdropClick", publicName: "closeOnBackdropClick", isSignal: true, isRequired: false, transformFunction: null }, closeOnEscape: { classPropertyName: "closeOnEscape", publicName: "closeOnEscape", isSignal: true, isRequired: false, transformFunction: null }, closeGuard: { classPropertyName: "closeGuard", publicName: "closeGuard", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, closeButtonTestId: { classPropertyName: "closeButtonTestId", publicName: "closeButtonTestId", isSignal: true, isRequired: false, transformFunction: null }, closeButtonAriaLabel: { classPropertyName: "closeButtonAriaLabel", publicName: "closeButtonAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, contentAriaLabel: { classPropertyName: "contentAriaLabel", publicName: "contentAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "ariaLabelledby", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", opened: "opened", closed: "closed" }, host: { properties: { "attr.data-tn-panel": "panelId" }, classAttribute: "tn-side-panel" }, queries: [{ propertyName: "actionContent", predicate: TnSidePanelActionDirective, isSignal: true }], viewQueries: [{ propertyName: "overlayRef", first: true, predicate: ["overlay"], descendants: true, isSignal: true }, { propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true, isSignal: true }, { propertyName: "contentRef", first: true, predicate: ["content"], descendants: true, isSignal: true }], ngImport: i0, template: "<!--\n Overlay wrapper: moved into a CDK overlay while the panel is open (#322), and\n back here \u2014 `display: none` \u2014 while it is not. `--open` and `--attached` are\n applied by the component rather than bound, because both have to land in a\n fixed order around the move; see the constants in `side-panel.component.ts`.\n\n A CLOSED panel is `inert` as well as `aria-hidden` (#214). It stays in the DOM\n and keeps its close button and its projected content, so `aria-hidden` alone\n hid a subtree a keyboard user could still Tab into \u2014 landing focus on controls\n that are translated off-screen. `inert` takes the subtree out of the focus\n order and the accessibility tree together; `aria-hidden` stays beside it for\n browsers without `inert` (Firefox before 112), where it is still the half that\n works. Same reasoning as the checkbox in `list-option.component.html`.\n\n `aria-label` and `aria-labelledby` are gated on `open()` for the same reason\n `aria-modal` is: a closed panel is not in the accessibility tree, so a name on\n it would describe nothing. `resolvedAriaLabelledby` prefers the visible\n heading. `resolvedAriaLabel` is `null` when the panel is named by a heading or\n an IDREF and the caller passed no `ariaLabel` of their own \u2014 an explicit one is\n always emitted, so that a dangling IDREF cannot leave the panel unnamed.\n\n `aria-hidden` is `hiddenFromAssistiveTech` rather than `!open()` because a\n dialog opened OVER the panel hides it too (#322) \u2014 the panel is still open\n underneath, which is why the other three stay on `open()`.\n-->\n<div\n #overlay\n class=\"tn-side-panel__overlay\"\n role=\"dialog\"\n tnTestIdType=\"side-panel\"\n [attr.data-tn-panel]=\"panelId\"\n [tnTestId]=\"testId()\"\n [class.tn-side-panel__overlay--initialized]=\"initialized()\"\n [attr.aria-modal]=\"open() ? 'true' : null\"\n [attr.aria-labelledby]=\"open() ? resolvedAriaLabelledby() : null\"\n [attr.aria-label]=\"open() ? resolvedAriaLabel() : null\"\n [attr.aria-hidden]=\"hiddenFromAssistiveTech() ? 'true' : null\"\n [attr.inert]=\"open() ? null : ''\">\n\n <!-- Backdrop -->\n @if (hasBackdrop()) {\n <div\n class=\"tn-side-panel__backdrop\"\n aria-hidden=\"true\"\n (click)=\"onBackdropClick()\">\n </div>\n }\n\n <!--\n Panel. `cdkTrapFocus` keeps Tab inside it while it is open; what moves focus\n IN on open is `tnFocusOnOpen` in the component, not\n `[cdkTrapFocusAutoCapture]`, which used to be here and could silently do\n nothing (#227). `tabindex=\"-1\"` is what makes this element the target it\n focuses \u2014 see `../a11y/initial-focus.ts` for why the container and not the\n first control inside it.\n\n No `(keydown)` here since #322: Escape arrives through CDK's overlay\n keyboard dispatcher, which knows which overlay is on top and this element\n does not.\n -->\n <div\n #panel\n class=\"tn-side-panel__panel\"\n tabindex=\"-1\"\n cdkTrapFocus\n [style.width]=\"width()\"\n (transitionend)=\"onTransitionEnd($event)\">\n\n <!-- Header -->\n <header class=\"tn-side-panel__header\">\n <!--\n No heading element at all when there is no title, rather than an empty\n one: an `<h2>` with no text is an `empty-heading` violation, and it puts\n a level-2 heading with nothing in it into the document outline.\n -->\n @if (hasTitle()) {\n <h2 class=\"tn-side-panel__title\" [id]=\"titleId\">{{ title() }}</h2>\n }\n <div class=\"tn-side-panel__header-actions\">\n <ng-content select=\"[tnSidePanelHeaderAction]\" />\n <tn-icon-button\n name=\"close\"\n library=\"mdi\"\n size=\"sm\"\n [ariaLabel]=\"closeButtonAriaLabel()\"\n [testId]=\"closeButtonTestId()\"\n (onClick)=\"dismiss()\" />\n </div>\n </header>\n\n <!--\n Content. This is the element that scrolls, and a scrolling element a\n keyboard cannot reach is content below the fold that a keyboard cannot\n read (#248). The panel's own `tabindex=\"-1\"` above does not cover it: that\n is a focus TARGET, not a tab stop, and it is on the container rather than\n on the element with the overflow.\n\n All three attributes are gated together, and the gate is the point \u2014 an\n unconditional tab stop would land on every panel in the library, including\n the ones whose content fits, where it announces a group and does nothing.\n\n The gate is `contentKeyboardReachable`, which is the measurement OR the\n region's own focus, not the measurement alone. Content that stops\n overflowing while the region is focused would otherwise take the tab stop\n off the focused element and blur a keyboard user out of the open panel.\n Both halves are `tnScrollableRegion`'s, in `../a11y/scrollable-region.ts`,\n which says why in full \u2014 including why it listens for this element's own\n `focus`/`blur` itself rather than leaving each caller to bind them.\n\n `role=\"group\"` rather than the `region` a named `<section>` would imply:\n `region` is a landmark, and a landmark named the same thing in every panel\n is what `landmark-unique` reports the moment two of them are open. A group\n is not summarised, which is what this is.\n -->\n <section\n #content\n class=\"tn-side-panel__content\"\n [attr.tabindex]=\"contentKeyboardReachable() ? '0' : null\"\n [attr.role]=\"contentKeyboardReachable() ? 'group' : null\"\n [attr.aria-label]=\"contentKeyboardReachable() ? contentAriaLabel() : null\">\n <ng-content />\n </section>\n\n <!-- Actions -->\n @if (hasActions()) {\n <footer class=\"tn-side-panel__actions\">\n <ng-content select=\"[tnSidePanelAction]\" />\n </footer>\n }\n </div>\n</div>\n", styles: [":host{display:contents}.tn-side-panel__overlay{position:fixed;inset:0;display:none;pointer-events:none}.tn-side-panel__overlay--attached{display:block}.tn-side-panel__overlay--open{pointer-events:auto}.tn-side-panel__overlay--open .tn-side-panel__backdrop{opacity:1}.tn-side-panel__overlay--open .tn-side-panel__panel{transform:translate(0);box-shadow:-4px 0 24px #0000004d}.tn-side-panel__backdrop{position:absolute;inset:0;background:#00000080;opacity:0}.tn-side-panel__overlay--initialized .tn-side-panel__backdrop{transition:opacity .2s ease}.tn-side-panel__panel{position:absolute;top:0;right:0;bottom:0;display:flex;flex-direction:column;max-width:100vw;background:var(--tn-bg2, #282828);color:var(--tn-fg1, #ffffff);box-shadow:-4px 0 24px #0000;outline:none;transform:translate(100%)}.tn-side-panel__overlay--initialized .tn-side-panel__panel{transition:transform .3s cubic-bezier(.4,0,.2,1),box-shadow .3s cubic-bezier(.4,0,.2,1)}.tn-side-panel__header{flex:0 0 auto;display:flex;align-items:center;gap:16px;padding:16px 24px;border-bottom:1px solid var(--tn-lines, #383838)}.tn-side-panel__title{margin:0;font-size:1.25rem;font-weight:600;line-height:1.5;color:var(--tn-fg1, #ffffff);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tn-side-panel__header-actions{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto}.tn-side-panel__content{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;padding:var(--tn-content-padding, 24px);-webkit-overflow-scrolling:touch;overflow-wrap:break-word}.tn-side-panel__actions{flex:0 0 auto;display:flex;justify-content:flex-end;gap:12px;padding:16px 24px;border-top:1px solid var(--tn-lines, #383838)}@media(max-width:640px){.tn-side-panel__panel{width:100vw!important}}@media(prefers-reduced-motion:reduce){.tn-side-panel__panel,.tn-side-panel__backdrop{transition-duration:0ms!important}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: i1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: TnIconButtonComponent, selector: "tn-icon-button", inputs: ["disabled", "dense", "ariaLabel", "ariaExpanded", "testId", "name", "size", "color", "tooltip", "tooltipPosition", "tooltipSticky", "library", "iconClass"], outputs: ["onClick"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
27299
27825
  }
27300
27826
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSidePanelComponent, decorators: [{
27301
27827
  type: Component,
27302
27828
  args: [{ selector: 'tn-side-panel', standalone: true, imports: [CommonModule, A11yModule, TnIconButtonComponent, TnTestIdDirective], host: {
27303
27829
  'class': 'tn-side-panel',
27304
27830
  '[attr.data-tn-panel]': 'panelId',
27305
- }, template: "<!--\n Overlay wrapper: portaled to document.body.\n\n A CLOSED panel is `inert` as well as `aria-hidden` (#214). It stays in the DOM\n and keeps its close button and its projected content, so `aria-hidden` alone\n hid a subtree a keyboard user could still Tab into \u2014 landing focus on controls\n that are translated off-screen. `inert` takes the subtree out of the focus\n order and the accessibility tree together; `aria-hidden` stays beside it for\n browsers without `inert` (Firefox before 112), where it is still the half that\n works. Same reasoning as the checkbox in `list-option.component.html`.\n\n `aria-label` and `aria-labelledby` are gated on `open()` for the same reason\n `aria-modal` is: a closed panel is not in the accessibility tree, so a name on\n it would describe nothing. `resolvedAriaLabelledby` prefers the visible\n heading. `resolvedAriaLabel` is `null` when the panel is named by a heading or\n an IDREF and the caller passed no `ariaLabel` of their own \u2014 an explicit one is\n always emitted, so that a dangling IDREF cannot leave the panel unnamed.\n-->\n<div\n #overlay\n class=\"tn-side-panel__overlay\"\n role=\"dialog\"\n tnTestIdType=\"side-panel\"\n [attr.data-tn-panel]=\"panelId\"\n [tnTestId]=\"testId()\"\n [class.tn-side-panel__overlay--initialized]=\"initialized()\"\n [class.tn-side-panel__overlay--open]=\"open()\"\n [attr.aria-modal]=\"open() ? 'true' : null\"\n [attr.aria-labelledby]=\"open() ? resolvedAriaLabelledby() : null\"\n [attr.aria-label]=\"open() ? resolvedAriaLabel() : null\"\n [attr.aria-hidden]=\"!open() ? 'true' : null\"\n [attr.inert]=\"open() ? null : ''\">\n\n <!-- Backdrop -->\n @if (hasBackdrop()) {\n <div\n class=\"tn-side-panel__backdrop\"\n aria-hidden=\"true\"\n (click)=\"onBackdropClick()\">\n </div>\n }\n\n <!--\n Panel. `cdkTrapFocus` keeps Tab inside it while it is open; what moves focus\n IN on open is `tnFocusOnOpen` in the component, not\n `[cdkTrapFocusAutoCapture]`, which used to be here and could silently do\n nothing (#227). `tabindex=\"-1\"` is what makes this element the target it\n focuses \u2014 see `../a11y/initial-focus.ts` for why the container and not the\n first control inside it.\n -->\n <div\n #panel\n class=\"tn-side-panel__panel\"\n tabindex=\"-1\"\n cdkTrapFocus\n [style.width]=\"width()\"\n (transitionend)=\"onTransitionEnd($event)\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Header -->\n <header class=\"tn-side-panel__header\">\n <!--\n No heading element at all when there is no title, rather than an empty\n one: an `<h2>` with no text is an `empty-heading` violation, and it puts\n a level-2 heading with nothing in it into the document outline.\n -->\n @if (hasTitle()) {\n <h2 class=\"tn-side-panel__title\" [id]=\"titleId\">{{ title() }}</h2>\n }\n <div class=\"tn-side-panel__header-actions\">\n <ng-content select=\"[tnSidePanelHeaderAction]\" />\n <tn-icon-button\n name=\"close\"\n library=\"mdi\"\n size=\"sm\"\n [ariaLabel]=\"closeButtonAriaLabel()\"\n [testId]=\"closeButtonTestId()\"\n (onClick)=\"dismiss()\" />\n </div>\n </header>\n\n <!--\n Content. This is the element that scrolls, and a scrolling element a\n keyboard cannot reach is content below the fold that a keyboard cannot\n read (#248). The panel's own `tabindex=\"-1\"` above does not cover it: that\n is a focus TARGET, not a tab stop, and it is on the container rather than\n on the element with the overflow.\n\n All three attributes are gated together, and the gate is the point \u2014 an\n unconditional tab stop would land on every panel in the library, including\n the ones whose content fits, where it announces a group and does nothing.\n\n The gate is `contentKeyboardReachable`, which is the measurement OR the\n region's own focus, not the measurement alone. Content that stops\n overflowing while the region is focused would otherwise take the tab stop\n off the focused element and blur a keyboard user out of the open panel.\n Both halves are `tnScrollableRegion`'s, in `../a11y/scrollable-region.ts`,\n which says why in full \u2014 including why it listens for this element's own\n `focus`/`blur` itself rather than leaving each caller to bind them.\n\n `role=\"group\"` rather than the `region` a named `<section>` would imply:\n `region` is a landmark, and a landmark named the same thing in every panel\n is what `landmark-unique` reports the moment two of them are open. A group\n is not summarised, which is what this is.\n -->\n <section\n #content\n class=\"tn-side-panel__content\"\n [attr.tabindex]=\"contentKeyboardReachable() ? '0' : null\"\n [attr.role]=\"contentKeyboardReachable() ? 'group' : null\"\n [attr.aria-label]=\"contentKeyboardReachable() ? contentAriaLabel() : null\">\n <ng-content />\n </section>\n\n <!-- Actions -->\n @if (hasActions()) {\n <footer class=\"tn-side-panel__actions\">\n <ng-content select=\"[tnSidePanelAction]\" />\n </footer>\n }\n </div>\n</div>\n", styles: [":host{display:contents}.tn-side-panel__overlay{position:fixed;inset:0;z-index:1000;pointer-events:none}.tn-side-panel__overlay--open{pointer-events:auto}.tn-side-panel__overlay--open .tn-side-panel__backdrop{opacity:1}.tn-side-panel__overlay--open .tn-side-panel__panel{transform:translate(0);box-shadow:-4px 0 24px #0000004d}.tn-side-panel__backdrop{position:absolute;inset:0;background:#00000080;opacity:0}.tn-side-panel__overlay--initialized .tn-side-panel__backdrop{transition:opacity .2s ease}.tn-side-panel__panel{position:absolute;top:0;right:0;bottom:0;display:flex;flex-direction:column;max-width:100vw;background:var(--tn-bg2, #282828);color:var(--tn-fg1, #ffffff);box-shadow:-4px 0 24px #0000;outline:none;transform:translate(100%)}.tn-side-panel__overlay--initialized .tn-side-panel__panel{transition:transform .3s cubic-bezier(.4,0,.2,1),box-shadow .3s cubic-bezier(.4,0,.2,1)}.tn-side-panel__header{flex:0 0 auto;display:flex;align-items:center;gap:16px;padding:16px 24px;border-bottom:1px solid var(--tn-lines, #383838)}.tn-side-panel__title{margin:0;font-size:1.25rem;font-weight:600;line-height:1.5;color:var(--tn-fg1, #ffffff);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tn-side-panel__header-actions{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto}.tn-side-panel__content{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;padding:var(--tn-content-padding, 24px);-webkit-overflow-scrolling:touch;overflow-wrap:break-word}.tn-side-panel__actions{flex:0 0 auto;display:flex;justify-content:flex-end;gap:12px;padding:16px 24px;border-top:1px solid var(--tn-lines, #383838)}@media(max-width:640px){.tn-side-panel__panel{width:100vw!important}}@media(prefers-reduced-motion:reduce){.tn-side-panel__panel,.tn-side-panel__backdrop{transition-duration:0ms!important}}\n"] }]
27831
+ }, template: "<!--\n Overlay wrapper: moved into a CDK overlay while the panel is open (#322), and\n back here \u2014 `display: none` \u2014 while it is not. `--open` and `--attached` are\n applied by the component rather than bound, because both have to land in a\n fixed order around the move; see the constants in `side-panel.component.ts`.\n\n A CLOSED panel is `inert` as well as `aria-hidden` (#214). It stays in the DOM\n and keeps its close button and its projected content, so `aria-hidden` alone\n hid a subtree a keyboard user could still Tab into \u2014 landing focus on controls\n that are translated off-screen. `inert` takes the subtree out of the focus\n order and the accessibility tree together; `aria-hidden` stays beside it for\n browsers without `inert` (Firefox before 112), where it is still the half that\n works. Same reasoning as the checkbox in `list-option.component.html`.\n\n `aria-label` and `aria-labelledby` are gated on `open()` for the same reason\n `aria-modal` is: a closed panel is not in the accessibility tree, so a name on\n it would describe nothing. `resolvedAriaLabelledby` prefers the visible\n heading. `resolvedAriaLabel` is `null` when the panel is named by a heading or\n an IDREF and the caller passed no `ariaLabel` of their own \u2014 an explicit one is\n always emitted, so that a dangling IDREF cannot leave the panel unnamed.\n\n `aria-hidden` is `hiddenFromAssistiveTech` rather than `!open()` because a\n dialog opened OVER the panel hides it too (#322) \u2014 the panel is still open\n underneath, which is why the other three stay on `open()`.\n-->\n<div\n #overlay\n class=\"tn-side-panel__overlay\"\n role=\"dialog\"\n tnTestIdType=\"side-panel\"\n [attr.data-tn-panel]=\"panelId\"\n [tnTestId]=\"testId()\"\n [class.tn-side-panel__overlay--initialized]=\"initialized()\"\n [attr.aria-modal]=\"open() ? 'true' : null\"\n [attr.aria-labelledby]=\"open() ? resolvedAriaLabelledby() : null\"\n [attr.aria-label]=\"open() ? resolvedAriaLabel() : null\"\n [attr.aria-hidden]=\"hiddenFromAssistiveTech() ? 'true' : null\"\n [attr.inert]=\"open() ? null : ''\">\n\n <!-- Backdrop -->\n @if (hasBackdrop()) {\n <div\n class=\"tn-side-panel__backdrop\"\n aria-hidden=\"true\"\n (click)=\"onBackdropClick()\">\n </div>\n }\n\n <!--\n Panel. `cdkTrapFocus` keeps Tab inside it while it is open; what moves focus\n IN on open is `tnFocusOnOpen` in the component, not\n `[cdkTrapFocusAutoCapture]`, which used to be here and could silently do\n nothing (#227). `tabindex=\"-1\"` is what makes this element the target it\n focuses \u2014 see `../a11y/initial-focus.ts` for why the container and not the\n first control inside it.\n\n No `(keydown)` here since #322: Escape arrives through CDK's overlay\n keyboard dispatcher, which knows which overlay is on top and this element\n does not.\n -->\n <div\n #panel\n class=\"tn-side-panel__panel\"\n tabindex=\"-1\"\n cdkTrapFocus\n [style.width]=\"width()\"\n (transitionend)=\"onTransitionEnd($event)\">\n\n <!-- Header -->\n <header class=\"tn-side-panel__header\">\n <!--\n No heading element at all when there is no title, rather than an empty\n one: an `<h2>` with no text is an `empty-heading` violation, and it puts\n a level-2 heading with nothing in it into the document outline.\n -->\n @if (hasTitle()) {\n <h2 class=\"tn-side-panel__title\" [id]=\"titleId\">{{ title() }}</h2>\n }\n <div class=\"tn-side-panel__header-actions\">\n <ng-content select=\"[tnSidePanelHeaderAction]\" />\n <tn-icon-button\n name=\"close\"\n library=\"mdi\"\n size=\"sm\"\n [ariaLabel]=\"closeButtonAriaLabel()\"\n [testId]=\"closeButtonTestId()\"\n (onClick)=\"dismiss()\" />\n </div>\n </header>\n\n <!--\n Content. This is the element that scrolls, and a scrolling element a\n keyboard cannot reach is content below the fold that a keyboard cannot\n read (#248). The panel's own `tabindex=\"-1\"` above does not cover it: that\n is a focus TARGET, not a tab stop, and it is on the container rather than\n on the element with the overflow.\n\n All three attributes are gated together, and the gate is the point \u2014 an\n unconditional tab stop would land on every panel in the library, including\n the ones whose content fits, where it announces a group and does nothing.\n\n The gate is `contentKeyboardReachable`, which is the measurement OR the\n region's own focus, not the measurement alone. Content that stops\n overflowing while the region is focused would otherwise take the tab stop\n off the focused element and blur a keyboard user out of the open panel.\n Both halves are `tnScrollableRegion`'s, in `../a11y/scrollable-region.ts`,\n which says why in full \u2014 including why it listens for this element's own\n `focus`/`blur` itself rather than leaving each caller to bind them.\n\n `role=\"group\"` rather than the `region` a named `<section>` would imply:\n `region` is a landmark, and a landmark named the same thing in every panel\n is what `landmark-unique` reports the moment two of them are open. A group\n is not summarised, which is what this is.\n -->\n <section\n #content\n class=\"tn-side-panel__content\"\n [attr.tabindex]=\"contentKeyboardReachable() ? '0' : null\"\n [attr.role]=\"contentKeyboardReachable() ? 'group' : null\"\n [attr.aria-label]=\"contentKeyboardReachable() ? contentAriaLabel() : null\">\n <ng-content />\n </section>\n\n <!-- Actions -->\n @if (hasActions()) {\n <footer class=\"tn-side-panel__actions\">\n <ng-content select=\"[tnSidePanelAction]\" />\n </footer>\n }\n </div>\n</div>\n", styles: [":host{display:contents}.tn-side-panel__overlay{position:fixed;inset:0;display:none;pointer-events:none}.tn-side-panel__overlay--attached{display:block}.tn-side-panel__overlay--open{pointer-events:auto}.tn-side-panel__overlay--open .tn-side-panel__backdrop{opacity:1}.tn-side-panel__overlay--open .tn-side-panel__panel{transform:translate(0);box-shadow:-4px 0 24px #0000004d}.tn-side-panel__backdrop{position:absolute;inset:0;background:#00000080;opacity:0}.tn-side-panel__overlay--initialized .tn-side-panel__backdrop{transition:opacity .2s ease}.tn-side-panel__panel{position:absolute;top:0;right:0;bottom:0;display:flex;flex-direction:column;max-width:100vw;background:var(--tn-bg2, #282828);color:var(--tn-fg1, #ffffff);box-shadow:-4px 0 24px #0000;outline:none;transform:translate(100%)}.tn-side-panel__overlay--initialized .tn-side-panel__panel{transition:transform .3s cubic-bezier(.4,0,.2,1),box-shadow .3s cubic-bezier(.4,0,.2,1)}.tn-side-panel__header{flex:0 0 auto;display:flex;align-items:center;gap:16px;padding:16px 24px;border-bottom:1px solid var(--tn-lines, #383838)}.tn-side-panel__title{margin:0;font-size:1.25rem;font-weight:600;line-height:1.5;color:var(--tn-fg1, #ffffff);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tn-side-panel__header-actions{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto}.tn-side-panel__content{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;padding:var(--tn-content-padding, 24px);-webkit-overflow-scrolling:touch;overflow-wrap:break-word}.tn-side-panel__actions{flex:0 0 auto;display:flex;justify-content:flex-end;gap:12px;padding:16px 24px;border-top:1px solid var(--tn-lines, #383838)}@media(max-width:640px){.tn-side-panel__panel{width:100vw!important}}@media(prefers-reduced-motion:reduce){.tn-side-panel__panel,.tn-side-panel__backdrop{transition-duration:0ms!important}}\n"] }]
27306
27832
  }], ctorParameters: () => [], propDecorators: { overlayRef: [{ type: i0.ViewChild, args: ['overlay', { isSignal: true }] }], panelRef: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }], contentRef: [{ type: i0.ViewChild, args: ['content', { isSignal: true }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], hasBackdrop: [{ type: i0.Input, args: [{ isSignal: true, alias: "hasBackdrop", required: false }] }], closeOnBackdropClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnBackdropClick", required: false }] }], closeOnEscape: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnEscape", required: false }] }], closeGuard: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeGuard", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], closeButtonTestId: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeButtonTestId", required: false }] }], closeButtonAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeButtonAriaLabel", required: false }] }], contentAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentAriaLabel", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], ariaLabelledby: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabelledby", required: false }] }], opened: [{ type: i0.Output, args: ["opened"] }], closed: [{ type: i0.Output, args: ["closed"] }], actionContent: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => TnSidePanelActionDirective), { isSignal: true }] }] } });
27307
27833
 
27308
27834
  /**
@@ -27323,7 +27849,8 @@ class TnSidePanelHarness extends ComponentHarness {
27323
27849
  .addOption('title', options.title, (harness, title) => HarnessPredicate.stringMatches(harness.getTitle(), title));
27324
27850
  }
27325
27851
  /**
27326
- * Locate the overlay wrapper, which may be portaled to document.body.
27852
+ * Locate the overlay wrapper, which is portaled into a CDK overlay while the
27853
+ * panel is open and sits inside the host element while it is not.
27327
27854
  * Uses the data-tn-panel attribute to correlate the host with its overlay.
27328
27855
  */
27329
27856
  async getOverlay() {
@@ -28398,6 +28925,42 @@ class TnFilePickerComponent {
28398
28925
  this.openFilePicker();
28399
28926
  }
28400
28927
  }
28928
+ /**
28929
+ * Escape, which closes the popup and CONSUMES the key.
28930
+ *
28931
+ * Opening the popup does not move focus into it, so the keystroke a user
28932
+ * actually makes starts on the path input — inside this host, not inside the
28933
+ * overlay. That is the path the `keydownEvents()` subscription in
28934
+ * `createOverlay` cannot serve: CDK's `OverlayKeyboardDispatcher` listens on
28935
+ * `<body>`, so any ancestor that stops the key first means it never runs.
28936
+ * `tn-drawer` in `over` mode is exactly such an ancestor — it calls
28937
+ * `stopPropagation()` on Escape from a handler on its own panel element — so
28938
+ * a picker left waiting for the dispatcher stayed open while the drawer
28939
+ * closed underneath it (#324).
28940
+ *
28941
+ * Consuming it also keeps a `tn-side-panel` from acting on the same key:
28942
+ * since #322 the panel is a CDK overlay subscribed through that dispatcher,
28943
+ * and closing this popup disposes its overlay mid-keystroke, leaving the
28944
+ * panel as the top-most subscriber for an Escape allowed to carry on.
28945
+ *
28946
+ * With the popup closed the key is NOT consumed: there is nothing to close
28947
+ * and it belongs to whatever contains this field. The inline-creation row is
28948
+ * unaffected either way — it lives in the popup, whose keydowns never reach
28949
+ * this host.
28950
+ *
28951
+ * Why the HOST rather than the input: `stopPropagation()` stops ANCESTOR
28952
+ * listeners, not other listeners on the same element. From here a `tnTooltip`
28953
+ * on this component still sees Escape and dismisses itself, and only the
28954
+ * containers ABOVE this field stop seeing it.
28955
+ */
28956
+ onHostKeydown(event) {
28957
+ if (event.key !== 'Escape' || !this.isOpen()) {
28958
+ return;
28959
+ }
28960
+ event.preventDefault();
28961
+ event.stopPropagation();
28962
+ this.close();
28963
+ }
28401
28964
  openFilePicker() {
28402
28965
  if (this.isOpen() || this.isDisabled()) {
28403
28966
  return;
@@ -28710,11 +29273,32 @@ class TnFilePickerComponent {
28710
29273
  this.overlayRef.backdropClick().subscribe(() => {
28711
29274
  this.close();
28712
29275
  });
29276
+ // Escape arriving from INSIDE the popup — a folder row or footer button
29277
+ // the user tabbed or clicked to. That keydown starts in the overlay, which
29278
+ // is not under this component's host, so `onHostKeydown` never sees it;
29279
+ // CDK's keyboard dispatcher does, and hands the key to the top-most
29280
+ // attached overlay that HAS subscribers, stopping there. Subscribing is
29281
+ // what makes this popup — rather than a `tn-side-panel` or dialog
29282
+ // underneath it — the thing that Escape closes (#324).
29283
+ //
29284
+ // Escape from the path input is the other half and is handled on the host,
29285
+ // because an ancestor can stop the key before the dispatcher runs.
29286
+ //
29287
+ // The inline-creation row inside the popup stops Escape from propagating
29288
+ // while it is open (see `onInlineCreationKeyDown`), so the key never
29289
+ // reaches the dispatcher and cancelling that row still wins over closing
29290
+ // the popup.
29291
+ this.overlayRef.keydownEvents().subscribe((event) => {
29292
+ if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
29293
+ event.preventDefault();
29294
+ this.close();
29295
+ }
29296
+ });
28713
29297
  this.portal = new TemplatePortal(this.filePickerTemplate(), this.viewContainerRef);
28714
29298
  this.overlayRef.attach(this.portal);
28715
29299
  }
28716
29300
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFilePickerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
28717
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.0", type: TnFilePickerComponent, isStandalone: true, selector: "tn-file-picker", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, multiSelect: { classPropertyName: "multiSelect", publicName: "multiSelect", isSignal: true, isRequired: false, transformFunction: null }, createActions: { classPropertyName: "createActions", publicName: "createActions", isSignal: true, isRequired: false, transformFunction: null }, allowManualInput: { classPropertyName: "allowManualInput", publicName: "allowManualInput", isSignal: true, isRequired: false, transformFunction: null }, openOnClick: { classPropertyName: "openOnClick", publicName: "openOnClick", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, startPath: { classPropertyName: "startPath", publicName: "startPath", isSignal: true, isRequired: false, transformFunction: null }, rootPath: { classPropertyName: "rootPath", publicName: "rootPath", isSignal: true, isRequired: false, transformFunction: null }, valueRoot: { classPropertyName: "valueRoot", publicName: "valueRoot", isSignal: true, isRequired: false, transformFunction: null }, fileExtensions: { classPropertyName: "fileExtensions", publicName: "fileExtensions", isSignal: true, isRequired: false, transformFunction: null }, callbacks: { classPropertyName: "callbacks", publicName: "callbacks", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", pathChange: "pathChange", createAction: "createAction", error: "error" }, host: { properties: { "class.error": "hasError()" }, classAttribute: "tn-file-picker" }, providers: [
29301
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.0", type: TnFilePickerComponent, isStandalone: true, selector: "tn-file-picker", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, multiSelect: { classPropertyName: "multiSelect", publicName: "multiSelect", isSignal: true, isRequired: false, transformFunction: null }, createActions: { classPropertyName: "createActions", publicName: "createActions", isSignal: true, isRequired: false, transformFunction: null }, allowManualInput: { classPropertyName: "allowManualInput", publicName: "allowManualInput", isSignal: true, isRequired: false, transformFunction: null }, openOnClick: { classPropertyName: "openOnClick", publicName: "openOnClick", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, startPath: { classPropertyName: "startPath", publicName: "startPath", isSignal: true, isRequired: false, transformFunction: null }, rootPath: { classPropertyName: "rootPath", publicName: "rootPath", isSignal: true, isRequired: false, transformFunction: null }, valueRoot: { classPropertyName: "valueRoot", publicName: "valueRoot", isSignal: true, isRequired: false, transformFunction: null }, fileExtensions: { classPropertyName: "fileExtensions", publicName: "fileExtensions", isSignal: true, isRequired: false, transformFunction: null }, callbacks: { classPropertyName: "callbacks", publicName: "callbacks", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", pathChange: "pathChange", createAction: "createAction", error: "error" }, host: { listeners: { "keydown": "onHostKeydown($event)" }, properties: { "class.error": "hasError()" }, classAttribute: "tn-file-picker" }, providers: [
28718
29302
  {
28719
29303
  provide: NG_VALUE_ACCESSOR,
28720
29304
  useExisting: forwardRef(() => TnFilePickerComponent),
@@ -28740,7 +29324,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
28740
29324
  }
28741
29325
  ], host: {
28742
29326
  'class': 'tn-file-picker',
28743
- '[class.error]': 'hasError()'
29327
+ '[class.error]': 'hasError()',
29328
+ // Escape only, and deliberately HERE rather than beside the input's own
29329
+ // bindings — see `onHostKeydown`.
29330
+ '(keydown)': 'onHostKeydown($event)'
28744
29331
  }, template: "<div class=\"tn-file-picker-container\" tnTestIdType=\"file-picker\" [tnTestId]=\"resolvedTestId()\">\n <div #wrapper ixInput class=\"tn-file-picker-wrapper\" style=\"padding-right: 40px;\">\n <input\n type=\"text\"\n class=\"tn-file-picker-input\"\n tnTestIdType=\"input\"\n [tnTestId]=\"resolvedTestId()\"\n [class.error]=\"hasError()\"\n [value]=\"selectedPath()\"\n [placeholder]=\"placeholder()\"\n [readonly]=\"!allowManualInput()\"\n [disabled]=\"isDisabled()\"\n (click)=\"onInputClick()\"\n (change)=\"onPathCommit($event)\">\n\n <button\n type=\"button\"\n class=\"tn-file-picker-toggle\"\n aria-label=\"Open file picker\"\n tnTestIdType=\"button\"\n [tnTestId]=\"toggleTestId()\"\n [disabled]=\"isDisabled()\"\n (click)=\"openFilePicker()\">\n <tn-icon name=\"folder\" library=\"mdi\" />\n </button>\n </div>\n \n <ng-template #filePickerTemplate>\n <tn-file-picker-popup\n class=\"tn-file-picker-popup\"\n tnTestIdType=\"file-picker-popup\"\n [tnTestId]=\"resolvedTestId()\"\n [testIdBase]=\"resolvedTestId()\"\n [mode]=\"mode()\"\n [multiSelect]=\"multiSelect()\"\n [createActions]=\"createActions()\"\n [currentPath]=\"currentPath()\"\n [rootPath]=\"effectiveRootPath()\"\n [fileItems]=\"fileItems()\"\n [selectedItems]=\"selectedItems()\"\n [loading]=\"loading()\"\n [fileExtensions]=\"fileExtensions()\"\n (itemClick)=\"onItemClick($event)\"\n (itemDoubleClick)=\"onItemDoubleClick($event)\"\n (pathNavigate)=\"navigateToPath($event)\"\n (createAction)=\"createAction.emit($event)\"\n (created)=\"onCreated($event)\"\n (clearSelection)=\"onClearSelection()\"\n (submit)=\"onSubmit()\"\n (cancel)=\"onCancel()\"\n (close)=\"close()\" />\n </ng-template>\n</div>", styles: [":host{display:block;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-file-picker-container{position:relative;display:flex;align-items:center;width:100%}.tn-file-picker-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-file-picker-input{display:block;width:100%;min-height:2.5rem;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;outline:none;box-sizing:border-box;font-family:inherit}.tn-file-picker-input::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-file-picker-input:focus{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px #007bff40}.tn-file-picker-input:disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-file-picker-input.error{border-color:var(--tn-error, var(--tn-red, #bd2626))}.tn-file-picker-input.error:focus{border-color:var(--tn-error, var(--tn-red, #bd2626));box-shadow:0 0 0 2px #dc354540}.tn-file-picker-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;color:var(--tn-fg1);border-radius:4px}.tn-file-picker-toggle:hover{background:var(--tn-bg2, #f0f0f0)}.tn-file-picker-toggle:focus{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-file-picker-toggle:disabled{cursor:not-allowed;opacity:.5}.tn-file-picker-toggle tn-icon{font-size:var(--tn-icon-md, 20px)}:host:focus-within .tn-file-picker-input{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px #007bff40}:host.error .tn-file-picker-input{border-color:var(--tn-error, var(--tn-red, #bd2626))}:host.error .tn-file-picker-input:focus{border-color:var(--tn-error, var(--tn-red, #bd2626));box-shadow:0 0 0 2px #dc354540}@media(prefers-reduced-motion:reduce){.tn-file-picker-input,.tn-file-picker-toggle,.file-item,.breadcrumb-segment{transition:none}.tn-file-picker-loading tn-icon{animation:none}}@media(prefers-contrast:high){.tn-file-picker-input{border-width:2px}.file-item:hover,.file-item.selected{border:2px solid var(--tn-fg1)}.zfs-badge{border:1px solid var(--tn-fg1)}}@media(max-width:768px){:host ::ng-deep .tn-file-picker-overlay .tn-file-picker-dialog{min-width:300px;max-width:calc(100vw - 32px);max-height:calc(100vh - 64px)}.tn-file-picker-header{flex-direction:column;gap:12px;align-items:stretch}.tn-file-picker-breadcrumb{overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}.tn-file-picker-breadcrumb::-webkit-scrollbar{display:none}.file-item{padding:12px;min-height:56px}.file-info{font-size:1rem}}\n"] }]
28745
29332
  }], propDecorators: { mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], multiSelect: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiSelect", required: false }] }], createActions: [{ type: i0.Input, args: [{ isSignal: true, alias: "createActions", required: false }] }], allowManualInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowManualInput", required: false }] }], openOnClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "openOnClick", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], startPath: [{ type: i0.Input, args: [{ isSignal: true, alias: "startPath", required: false }] }], rootPath: [{ type: i0.Input, args: [{ isSignal: true, alias: "rootPath", required: false }] }], valueRoot: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueRoot", required: false }] }], fileExtensions: [{ type: i0.Input, args: [{ isSignal: true, alias: "fileExtensions", required: false }] }], callbacks: [{ type: i0.Input, args: [{ isSignal: true, alias: "callbacks", required: false }] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], pathChange: [{ type: i0.Output, args: ["pathChange"] }], createAction: [{ type: i0.Output, args: ["createAction"] }], error: [{ type: i0.Output, args: ["error"] }], wrapperEl: [{ type: i0.ViewChild, args: ['wrapper', { isSignal: true }] }], filePickerTemplate: [{ type: i0.ViewChild, args: ['filePickerTemplate', { isSignal: true }] }] } });
28746
29333