diff --git a/apps/docs/src/app/components/popover-example/popover-example.component.ts b/apps/docs/src/app/components/popover-example/popover-example.component.ts index 71b84ec333..45486e72ef 100644 --- a/apps/docs/src/app/components/popover-example/popover-example.component.ts +++ b/apps/docs/src/app/components/popover-example/popover-example.component.ts @@ -28,7 +28,7 @@ import { KbqTopBarModule } from '@koobiq/components/top-bar'; navigating away from the current view.

- diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index 76f2bbaa45..67db40028a 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -1049,6 +1049,26 @@ Most of them report rather than rewrite: what replaces a removed member or a sig ng g @koobiq/components: --project ``` +#### Popover + +Hover mode was broken end to end by a dead expression. `this.leaveDelay ?? 500` looks like a default, but the base class sets the field to `0`, and `0 ?? 500` is `0` — so the panel closed before the pointer could cross the 8px gap to it, the documented interactive content was unreachable even for pointer users, and the auto-hide watchdog spun as an `interval(0)` for as long as the panel stayed open. + +The delay is derived from the trigger now, and `kbqLeaveDelay` is a write-only input that records having been bound. Bound in the template, the bound value stands; not bound, the `trigger` setter re-derives the delay on every change, so a popover switched to `hover` later gets the hover default instead of the `0` it was born with. + +A programmatic `trigger.leaveDelay = 500` records nothing, so the next write to `trigger` overwrites it. That is the one change here with no compile error behind it. + +| Pattern | Manual migration | +| ----------------- | ------------------------------------------------------------------------------------- | +| `.leaveDelay = …` | Bind `[kbqLeaveDelay]`, or drop it and take the hover default — it is long enough now | +| `.onConfirm = …` | `onConfirm` is readonly; subscribe instead of replacing it | +| `placementChange` | Emits `string` instead of `any`; only a payload assigned to a non-string breaks | + +The confirm popover no longer hardcodes its Russian defaults: «Вы уверены, что хотите продолжить?» and «Да» come from the locale now, so a non-RU application renders translated text where it used to render Russian. + +Two fixes with nothing to migrate: the trigger subscribed to the global `ScrollDispatcher` with no teardown in the _default_ configuration, and that subscription is bounded now — a host that worked around the leak by destroying triggers eagerly can stop. And `KbqPopoverTrigger` can be imported standalone, because the scroll-strategy provider is no longer NgModule-only. + +Reported by `popover-leave-delay`. + #### Search expandable Step 4 already renames the `kbq-filter-search` element to `kbq-search-expandable`. That rewrite only ever touched the tag, so the inputs of the removed `KbqFilterBarSearch` survived as attributes the new component does not have — silently, because an unknown attribute on a component is not an error. `v20-upgrade` renames them too now: diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index a7d545361d..f580e48d90 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -1053,6 +1053,26 @@ ng update @koobiq/components@20 ng g @koobiq/components: --project ``` +#### Popover + +Режим по наведению был сломан целиком из-за мёртвого выражения. `this.leaveDelay ?? 500` выглядит как значение по умолчанию, но базовый класс присваивает полю `0`, а `0 ?? 500` — это `0`. Панель закрывалась раньше, чем указатель успевал пересечь зазор в 8px до неё, задокументированное интерактивное содержимое было недостижимо даже для мыши, а таймер автоматического скрытия крутился как `interval(0)` всё время, пока панель открыта. + +Теперь задержка выводится из триггера, а `kbqLeaveDelay` — вход только на запись, который запоминает сам факт привязки. Если привязка есть в шаблоне, действует привязанное значение; если нет — сеттер `trigger` пересчитывает задержку при каждом изменении, поэтому поповер, переключённый на `hover` позже, получает значение по умолчанию для наведения, а не `0`, с которым он был создан. + +Программное присваивание `trigger.leaveDelay = 500` ничего не запоминает, поэтому следующая запись в `trigger` его перезапишет. Это единственное изменение здесь, за которым не стоит ошибки компиляции. + +| Что было | Что делать вручную | +| ----------------- | --------------------------------------------------------------------------------------- | +| `.leaveDelay = …` | Привязать `[kbqLeaveDelay]` либо убрать вовсе — значения по умолчанию теперь достаточно | +| `.onConfirm = …` | `onConfirm` доступен только для чтения: подписывайтесь вместо замены | +| `placementChange` | Отдаёт `string` вместо `any`; ломается только присваивание значения не в строку | + +Поповер подтверждения больше не зашивает русские значения по умолчанию: «Вы уверены, что хотите продолжить?» и «Да» берутся из локали, поэтому не-русское приложение получит переведённый текст там, где раньше был русский. + +Два исправления, для которых мигрировать нечего: в _конфигурации по умолчанию_ триггер подписывался на глобальный `ScrollDispatcher` без отписки — теперь подписка ограничена по времени жизни, и хост, обходивший утечку принудительным уничтожением триггеров, может это убрать. И `KbqPopoverTrigger` можно импортировать как standalone: провайдер стратегии скролла больше не живёт только в NgModule. + +Сообщает `popover-leave-delay`. + #### Search expandable Шаг 4 уже переименовывает элемент `kbq-filter-search` в `kbq-search-expandable`. Но та замена трогала только тег, поэтому входы удалённого `KbqFilterBarSearch` оставались в разметке атрибутами, которых у нового компонента нет, — и молча, потому что неизвестный атрибут на компоненте не является ошибкой. Теперь `v20-upgrade` переименовывает и их: diff --git a/packages/components-dev/popover/template.html b/packages/components-dev/popover/template.html index 7fff206aa1..f95c96053e 100644 --- a/packages/components-dev/popover/template.html +++ b/packages/components-dev/popover/template.html @@ -58,7 +58,7 @@ kbqPopover [kbqPopoverContent]="'Popover content'" [kbqTooltip]="'Backdrop trigger tooltip'" - [hasBackdrop]="true" + [kbqPopoverHasBackdrop]="true" > Popover with backdrop @@ -334,7 +334,7 @@

Configuration:

diff --git a/packages/components/popover/popover-animations.ts b/packages/components/popover/popover-animations.ts index 3dd069b960..43f14eb315 100644 --- a/packages/components/popover/popover-animations.ts +++ b/packages/components/popover/popover-animations.ts @@ -1,9 +1,24 @@ import { animate, AnimationTriggerMetadata, state, style, transition, trigger } from '@angular/animations'; +import { KbqAnimationCurves, KbqAnimationDurations } from '@koobiq/components/core'; +/** Duration of the opening animation. */ +const enterDuration = '120ms'; + +/** Duration of the closing animation. */ +const exitDuration = KbqAnimationDurations.Rapid; + +/** + * Animation that transitions a popover in and out. + * + * Motion is opt-out rather than unconditional: `KbqPopoverComponent` binds `[@.disabled]` to the user's + * `prefers-reduced-motion` setting, so the same trigger renders instantly for those users. + * + * @docs-private + */ export const kbqPopoverAnimations: { readonly popoverState: AnimationTriggerMetadata; } = { - /** Animation that transitions a tooltip in and out. */ + /** Animation that transitions a popover in and out. */ popoverState: trigger('state', [ state( 'initial', @@ -15,13 +30,13 @@ export const kbqPopoverAnimations: { transition( '* => visible', animate( - '120ms cubic-bezier(0, 0, 0.2, 1)', + `${enterDuration} ${KbqAnimationCurves.DecelerationCurve}`, style({ opacity: 1, transform: 'scale(1, 1)' }) ) ), - transition('* => hidden', animate('100ms linear', style({ opacity: 0 }))) + transition('* => hidden', animate(`${exitDuration} linear`, style({ opacity: 0 }))) ]) }; diff --git a/packages/components/popover/popover-confirm.component.html b/packages/components/popover/popover-confirm.component.html index ddf001ddaf..595cc2580f 100644 --- a/packages/components/popover/popover-confirm.component.html +++ b/packages/components/popover/popover-confirm.component.html @@ -1,20 +1,26 @@ - @if (hasCloseButton && header) { -
- + @if (hasCloseButton) { +
+
+ +
} - -
- -
-
- @if (arrow) {
} diff --git a/packages/components/popover/popover.component.ts b/packages/components/popover/popover.component.ts index 9d53f5f45e..7b155f5f7a 100644 --- a/packages/components/popover/popover.component.ts +++ b/packages/components/popover/popover.component.ts @@ -1,5 +1,11 @@ import { AnimationEvent } from '@angular/animations'; -import { CdkTrapFocus } from '@angular/cdk/a11y'; +import { + CdkTrapFocus, + ConfigurableFocusTrapFactory, + FOCUS_TRAP_INERT_STRATEGY, + FocusMonitor, + FocusTrapFactory +} from '@angular/cdk/a11y'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CdkObserveContent } from '@angular/cdk/observers'; import { @@ -9,7 +15,7 @@ import { ScrollDispatcher, ScrollStrategy } from '@angular/cdk/overlay'; -import { NgTemplateOutlet } from '@angular/common'; +import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; import { AfterContentInit, AfterViewInit, @@ -23,11 +29,13 @@ import { Input, OnInit, Output, + Provider, TemplateRef, Type, ViewChild, ViewEncapsulation, booleanAttribute, + computed, inject, input, numberAttribute, @@ -36,6 +44,8 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButtonModule } from '@koobiq/components/button'; import { + EmptyFocusTrapStrategy, + KBQ_WINDOW, KbqComponentColors, KbqOverflowShadowBottom, KbqOverflowShadowContainer, @@ -61,6 +71,30 @@ import { kbqPopoverAnimations } from './popover-animations'; export const defaultOffsetYWithArrow = 8; +/** Leave delay applied to a hover-triggered popover when `kbqLeaveDelay` is not bound. */ +export const defaultHoverLeaveDelay = 500; + +/** Debounce of the content-mutation observer that drives the scroll shadows, in milliseconds. */ +const contentObserverDebounce = 15; + +/** DOM event names the shared pop-up base records right before opening on a pointer or focus event. */ +const passiveOpenEvents = ['mouseenter', 'focus']; + +let nextUniqueId = 0; + +/** + * Focus-trap wiring of a popover panel. + * + * Provided on the panel components rather than on `KbqPopoverModule`: both entries are injector-wide, so at + * module level they replaced the CDK inert strategy for every other focus trap in the importing injector. + * + * @docs-private + */ +export const KBQ_POPOVER_FOCUS_TRAP_PROVIDERS: Provider[] = [ + { provide: FocusTrapFactory, useClass: ConfigurableFocusTrapFactory }, + { provide: FOCUS_TRAP_INERT_STRATEGY, useClass: EmptyFocusTrapStrategy } +]; + @Component({ selector: 'kbq-popover-component', imports: [ @@ -76,10 +110,12 @@ export const defaultOffsetYWithArrow = 8; ], templateUrl: './popover.component.html', styleUrls: ['./popover.scss', './popover-tokens.scss'], + providers: KBQ_POPOVER_FOCUS_TRAP_PROVIDERS, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { - '(keydown.esc)': 'onEscape()' + '(keydown.esc)': 'onEscape()', + '[@.disabled]': 'reducedMotion' }, animations: [kbqPopoverAnimations.popoverState], preserveWhitespaces: false @@ -88,6 +124,13 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { /** Accessible name for the icon-only close button. */ protected readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration(); + /** Whether the opening/closing animation must be skipped because the user asked for reduced motion. */ + protected readonly reducedMotion: boolean = + inject(KBQ_WINDOW).matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + + /** Debounce shared by the mutation observer and the scroll-shadow container (both expose `debounce`). */ + protected readonly contentObserverDebounce = contentObserverDebounce; + prefix = 'kbq-popover'; header: string | TemplateRef; @@ -98,6 +141,19 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { isTrapFocus: boolean = false; hasCloseButton: boolean = false; + /** Id of the panel element, referenced by the trigger's `aria-controls`. Written by the trigger. */ + panelId: string; + + /** Accessible name of the panel, used when there is no header to label it. Written by the trigger. */ + ariaLabel: string | undefined; + + /** + * Panel element (`.kbq-popover`), not the component host. + * + * Deliberately shadows the injected `KbqPopUp.elementRef`: the base measures and decorates the visible + * panel, so both `applyPopupMargins` here and `setStickPosition`/`addEventListenerForHide` in the base + * must resolve to the same element. The confirm template carries the matching `#popover` reference. + */ @ViewChild('popover') elementRef: ElementRef; readonly cdkTrapFocus = viewChild.required(CdkTrapFocus); /** @docs-private */ @@ -105,6 +161,19 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { private readonly scrollbarViewport = viewChild(KbqScrollbarViewport); + /** Id of the header text node, referenced by the panel's `aria-labelledby`. */ + get headerId(): string { + return `${this.panelId}-header`; + } + + /** + * Whether the header can name the dialog. Only a string header renders a text node with an id to point + * `aria-labelledby` at; a template header is arbitrary markup, so those panels fall back to `ariaLabel`. + */ + protected get labelledByHeader(): boolean { + return !!this.header && !this.isTemplateRef(this.header); + } + ngAfterViewInit() { this.visibleChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((state) => { if (this.offset !== null && state && this.elementRef) { @@ -117,9 +186,19 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { this.setStickPosition(); } + + // Every closing path ends here, and the panel is still in the DOM at this point, so this is the + // one place where focus can be handed back to the trigger instead of falling to ``. + if (!state) { + this.trigger?.restoreFocus(); + } }); - this.cdkTrapFocus().focusTrap.focusFirstTabbableElement(); + // A hover- or focus-triggered popover is dismissed by the pointer leaving it, so capturing the + // keyboard would strand focus on `` when it auto-hides. + if (this.trigger?.capturesFocusOnOpen ?? true) { + this.cdkTrapFocus().focusTrap.focusFirstTabbableElement(); + } } updateClassMap(placement: string, customClass: string, size: KbqPopUpSizeValues) { @@ -131,9 +210,38 @@ export class KbqPopoverComponent extends KbqPopUp implements AfterViewInit { } onEscape() { - this.hide(0); + this.requestClose(); + } + + /** Handles a click on the close button. */ + protected onClose(): void { + this.requestClose(); + } - this.trigger.focus(); + /** + * Closes the panel through its trigger, so that `kbqPopoverPreventClose` is honored and focus is restored + * the same way as on every other closing path. + * + * The component is exported and can be rendered without a trigger — `ngAfterViewInit` already allows for + * that — and such a panel has no `preventClose` to honor and no trigger to hand focus back to, so it hides + * itself rather than throwing on a keypress. + */ + private requestClose(): void { + if (this.trigger) { + this.trigger.close(); + } else { + this.hide(0); + } + } + + /** + * Whether the scrollable content region needs its own tab stop and landmark (AXE + * `scrollable-region-focusable`). An unconditional `tabindex` would add a stop to every popover. + */ + protected isScrollableRegion(container: KbqOverflowShadowContainer): boolean { + const { top, bottom } = container.overflow(); + + return top || bottom; } override animationDone(event: AnimationEvent): void { @@ -164,7 +272,12 @@ export const KBQ_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER = { useFactory: kbqPopoverScrollStrategyFactory }; -/** Creates an error to be thrown if the user supplied an invalid popover position. */ +/** + * Creates an error to be thrown if the user supplied an invalid popover position. + * + * @deprecated An invalid placement is not fatal: it is reported with a warning and falls back to `top`. + * Will be removed in the next major release. + */ export function getKbqPopoverInvalidPositionError(position: string) { return Error(`KbqPopover position "${position}" is invalid.`); } @@ -175,6 +288,9 @@ export function getKbqPopoverInvalidPositionError(position: string) { host: { '[class.kbq-popover_open]': 'isOpen', '[class.kbq-active]': 'hasClickTrigger && isOpen', + '[attr.aria-expanded]': 'hasClickTrigger ? isOpen : null', + '[attr.aria-haspopup]': 'hasClickTrigger ? "dialog" : null', + '[attr.aria-controls]': 'hasClickTrigger && isOpen ? panelId : null', '(keydown)': 'keydownHandler($event)', '(touchend)': 'touchendHandler()' }, @@ -183,19 +299,51 @@ export function getKbqPopoverInvalidPositionError(position: string) { export class KbqPopoverTrigger extends KbqPopUpTrigger implements AfterContentInit, OnInit { protected scrollStrategy: () => ScrollStrategy = inject(KBQ_POPOVER_SCROLL_STRATEGY); - /** Controls whether the component should be hidden when it is not visible in the viewport. */ + private readonly focusMonitor = inject(FocusMonitor); + private readonly document = inject(DOCUMENT); + + /** Id of the panel this trigger controls, shared with `aria-controls`. */ + readonly panelId: string = `kbq-popover-${nextUniqueId++}`; + + /** + * Controls whether the component should be hidden when it is not visible in the viewport. + * + * @deprecated Use `kbqPopoverHideIfNotInViewPort`. The unprefixed alias will be removed in the next + * major release. + */ readonly hideIfNotInViewPort = input(true, { transform: booleanAttribute }); + /** + * Input (`kbqPopoverHideIfNotInViewPort`) — prefixed alias of {@link hideIfNotInViewPort}. Left + * `undefined` when it is not bound, so the deprecated alias keeps winning until it is removed. + */ + readonly popoverHideIfNotInViewPort = input(undefined, { + alias: 'kbqPopoverHideIfNotInViewPort', + transform: (value: unknown) => (value == null ? undefined : booleanAttribute(value)) + }); + /** prevents closure by any event */ // TODO: Skipped for migration because: // Your application code writes to the input. This prevents migration. @Input({ alias: 'kbqPopoverPreventClose', transform: booleanAttribute }) override preventClose: boolean = false; - /** disables default padding for all popover elements (header, content and footer) */ + /** + * disables default padding for all popover elements (header, content and footer) + * + * @deprecated Use `kbqPopoverDefaultPaddings`. The unprefixed alias will be removed in the next major + * release. + */ // TODO: Skipped for migration because: // Class of this input is referenced in the signature of another class. @Input({ transform: booleanAttribute }) defaultPaddings = true; + /** Input (`kbqPopoverDefaultPaddings`) — prefixed alias of {@link defaultPaddings}. */ + @Input({ alias: 'kbqPopoverDefaultPaddings', transform: booleanAttribute }) + set popoverDefaultPaddings(value: boolean) { + this.defaultPaddings = value; + } + + /** Input (`kbqPopoverVisible`) — opens the popover when set to `true` and closes it when set to `false`. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverVisible') @@ -207,6 +355,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl super.updateVisible(value); } + /** Input (`kbqPopoverPlacement`) — preferred placement of the panel relative to the trigger. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverPlacement') @@ -218,6 +367,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl super.updatePlacement(value); } + /** Input (`kbqPopoverPlacementPriority`) — ordered placements tried before the default strategy. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverPlacementPriority') @@ -238,12 +388,28 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl // is not migrated. @Input('kbqPopoverStickToWindow') stickToWindow: KbqStickToWindowPlacementValues; - /** Container for additional positioning, used with kbqPopoverStickToWindow */ + /** + * Container for additional positioning, used with kbqPopoverStickToWindow + * + * @deprecated Use `kbqPopoverContainer`. The unprefixed alias will be removed in the next major release. + */ // TODO: Skipped for migration because: // This input overrides a field from a superclass, while the superclass field // is not migrated. @Input() container: HTMLElement; + /** Input (`kbqPopoverContainer`) — prefixed alias of {@link container}. */ + @Input('kbqPopoverContainer') + set popoverContainer(value: HTMLElement) { + this.container = value; + } + + /** + * Input (`hasBackdrop`) — whether a backdrop is rendered behind the panel. With a backdrop the popover + * closes on a backdrop click instead of on any outside pointer event. + * + * @deprecated Use `kbqPopoverHasBackdrop`. The unprefixed alias will be removed in the next major release. + */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input() @@ -253,10 +419,19 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl set hasBackdrop(value: boolean) { this._hasBackdrop = coerceBooleanProperty(value); + + this.invalidateOverlay(); } private _hasBackdrop: boolean = false; + /** Input (`kbqPopoverHasBackdrop`) — prefixed alias of {@link hasBackdrop}. */ + @Input('kbqPopoverHasBackdrop') + set popoverHasBackdrop(value: boolean) { + this.hasBackdrop = value; + } + + /** Input (`kbqPopoverHeader`) — header of the panel, as a string or a template. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverHeader') @@ -272,6 +447,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl private _header: string | TemplateRef; + /** Input (`kbqPopoverContent`) — content of the panel, as a string or a template. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverContent') @@ -285,6 +461,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl this.updateData(); } + /** Input (`kbqPopoverFooter`) — footer of the panel, as a string or a template. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverFooter') @@ -300,6 +477,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl private _footer: string | TemplateRef; + /** Input (`kbqPopoverDisabled`) — disables the trigger and closes an open popover. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverDisabled') @@ -315,6 +493,11 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl } } + /** + * Input (`kbqTrigger`) with the comma-separated trigger events. An empty value resets to click + keydown + * and rebinds listeners. The alias is shared with `kbqTooltip`, so binding it on an element that carries + * both reconfigures both. + */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqTrigger') @@ -328,17 +511,24 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl if (this.trigger.includes(PopUpTriggers.Hover)) { this.hideWithTimeout = true; - this.leaveDelay = this.leaveDelay ?? 500; } } else { this._trigger = `${PopUpTriggers.Click}, ${PopUpTriggers.Keydown}`; } + // Re-derived from the trigger on every change rather than applied once to an unset delay: a popover + // switched to `hover` later closes on the 0 it was born with otherwise, leaving no time to cross the + // gap between the trigger and the panel. + if (!this.leaveDelayBound) { + this.leaveDelay = this._trigger.includes(PopUpTriggers.Hover) ? defaultHoverLeaveDelay : 0; + } + this.initListeners(); } private _trigger: string = `${PopUpTriggers.Click}, ${PopUpTriggers.Keydown}`; + /** Input (`kbqPopoverSize`) — preset width of the panel. An unknown value falls back to `medium`. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverSize') @@ -353,11 +543,15 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl this.updateClassMap(); } else { this._size = PopUpSizes.Medium; + + // eslint-disable-next-line no-console + console.warn(`Unknown size: ${value}. Will use the default size: ${this._size}`); } } private _size: KbqPopUpSizeValues = PopUpSizes.Medium; + /** Input (`kbqPopoverClass`) with extra CSS classes applied to the panel. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('kbqPopoverClass') @@ -386,22 +580,62 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl private _context: unknown = null; + /** + * Accessible name of the panel. Ignored while a string `kbqPopoverHeader` is set — the header labels the + * dialog then — and recommended for every header-less popover. + */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. - @Input() - get hasCloseButton() { + @Input('kbqPopoverAriaLabel') + get ariaLabel(): string | undefined { + return this._ariaLabel; + } + + set ariaLabel(value: string | undefined) { + this._ariaLabel = value; + + this.updateData(); + } + + private _ariaLabel: string | undefined; + + /** + * Input (`hasCloseButton`) — renders the icon-only close button in the top corner of the panel. + * + * @deprecated Use `kbqPopoverHasCloseButton`. The unprefixed alias will be removed in the next major + * release. + */ + // TODO: Skipped for migration because: + // Accessor inputs cannot be migrated as they are too complex. + @Input({ transform: booleanAttribute }) + get hasCloseButton(): boolean { return this._hasCloseButton; } - set hasCloseButton(value) { + + set hasCloseButton(value: boolean) { this._hasCloseButton = value; this.updateData(); } + private _hasCloseButton = false; + /** Input (`kbqPopoverHasCloseButton`) — prefixed alias of {@link hasCloseButton}. */ + @Input({ alias: 'kbqPopoverHasCloseButton', transform: booleanAttribute }) + set popoverHasCloseButton(value: boolean) { + this.hasCloseButton = value; + } + /** - * Controls the behavior of closing the component on scroll. - * The default value is `false`. + * Controls the behavior of closing the component on scroll. Three states: + * - `null` (default) — the popover survives a scroll, except when it scrolls out of a + * `.kbq-hide-nested-popup` container, which closes it; + * - `true` — any scroll closes the popover; + * - `false` — no scroll closes the popover, not even a `.kbq-hide-nested-popup` one. + * * Use CloseScrollStrategy as alternative + * + * @deprecated Use `kbqPopoverCloseOnScroll`. The unprefixed alias will be removed in the next major + * release. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @@ -416,40 +650,122 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl private _closeOnScroll: boolean | null = null; + /** Input (`kbqPopoverCloseOnScroll`) — prefixed alias of {@link closeOnScroll}. */ + @Input('kbqPopoverCloseOnScroll') + set popoverCloseOnScroll(value: boolean) { + this.closeOnScroll = value; + } + + /** Whether the trigger opens the popover on click, which is also what makes it a labelled dialog opener. */ get hasClickTrigger(): boolean { return this.trigger.includes(PopUpTriggers.Click); } + /** + * Whether opening the popover should move the keyboard focus into the panel. Only deliberate opens + * (click, keyboard, programmatic) do — a hover- or focus-triggered popover must not steal focus. + */ + get capturesFocusOnOpen(): boolean { + return this.hasClickTrigger || this.deliberateOpen; + } + /** @docs-private */ get instanceDestroyRef(): DestroyRef { return this.instance.destroyRef; } + /** + * Input (`backdropClass`) — CSS class applied to the backdrop, when there is one. + * + * @deprecated Use `kbqPopoverBackdropClass`. The unprefixed alias will be removed in the next major + * release. + */ // TODO: Skipped for migration because: // Class of this input is referenced in the signature of another class. - @Input() backdropClass: string = 'cdk-overlay-transparent-backdrop'; + @Input() + get backdropClass(): string { + return this._backdropClass; + } - // @TODO add realization for arrow (#DS-2514) + set backdropClass(value: string) { + this._backdropClass = value; + + this.invalidateOverlay(); + } + + private _backdropClass: string = 'cdk-overlay-transparent-backdrop'; + + /** Input (`kbqPopoverBackdropClass`) — prefixed alias of {@link backdropClass}. */ + @Input('kbqPopoverBackdropClass') + set popoverBackdropClass(value: string) { + this.backdropClass = value; + } + + /** Input (`kbqPopoverArrow`) — renders the arrow pointing at the trigger. */ // TODO: Skipped for migration because: // This input overrides a field from a superclass, while the superclass field // is not migrated. @Input({ alias: 'kbqPopoverArrow', transform: booleanAttribute }) arrow: boolean = true; + /** Input (`kbqPopoverOffset`) — distance between the trigger and the panel, in pixels. */ // TODO: Skipped for migration because: // Class of this input is referenced in the signature of another class. @Input({ alias: 'kbqPopoverOffset', transform: numberAttribute }) offset: number | null = defaultOffsetYWithArrow; - /** Delay before closing in milliseconds. The default value for kbqTrigger=PopUpTriggers.Hover is 500 ms. */ + /** Input (`kbqEnterDelay`) — delay before opening, in milliseconds. Defaults to `0`. */ // TODO: Skipped for migration because: - // Your application code writes to the input. This prevents migration. - @Input({ alias: 'kbqLeaveDelay', transform: numberAttribute }) leaveDelay: number; + // This input overrides a field from a superclass, while the superclass field + // is not migrated. + @Input({ alias: 'kbqEnterDelay', transform: numberAttribute }) enterDelay: number = 0; - @Output('kbqPopoverPlacementChange') readonly placementChange = new EventEmitter(); + /** + * Input (`kbqLeaveDelay`) — delay before closing, in milliseconds. Defaults to `0`, and to + * {@link defaultHoverLeaveDelay} when `kbqTrigger` includes `hover`. + */ + // A write-only input rather than an accessor over `leaveDelay`: the base declares that member as a + // plain field, and TypeScript refuses to override a property with an accessor (TS2611). + @Input({ transform: numberAttribute }) + set kbqLeaveDelay(value: number) { + this.leaveDelay = value; + // Tracked instead of comparing the delay against a sentinel: an explicit `kbqLeaveDelay="0"` and an + // unbound one are indistinguishable by value, and only the latter takes the hover default. + this.leaveDelayBound = true; + } + + private leaveDelayBound = false; + + /** + * Emits the resolved placement whenever the panel is repositioned. + * + * Declared as `string` to match the abstract member on `KbqPopUpTrigger`: `EventEmitter` is invariant, + * so a narrower emitter cannot override a wider one. Narrowing both is a change to the shared pop-up + * base and belongs to the branch that owns it. + */ + @Output('kbqPopoverPlacementChange') readonly placementChange = new EventEmitter(); + /** Emits `true` when the panel opens and `false` when it closes. */ @Output('kbqPopoverVisibleChange') readonly visibleChange = new EventEmitter(); protected originSelector = '.kbq-popover'; + /** Resolved value of the two `hideIfNotInViewPort` aliases. */ + private readonly shouldHideIfNotInViewPort = computed( + () => this.popoverHideIfNotInViewPort() ?? this.hideIfNotInViewPort() + ); + + /** Whether a position re-apply is already queued for this turn. */ + private repositionScheduled = false; + + /** + * Whether the open currently on screen was a deliberate one rather than a pointer or focus one. + * + * The shared base records the event that opened the pop-up in `triggerName`, but its keyboard opener is + * the one listener registered without that bookkeeping, and it defers the open to a task of its own — + * by then `triggerName` still holds whatever the pointer last did. Frozen on every `show()`, which the + * pointer and focus listeners reach with the name they have just written. + */ + private deliberateOpen = true; + // NB: the trigger↔popover gap is a CSS `margin` on `.kbq-popover` (see `applyPopupMargins`), which sits inside // this `pointer-events: auto` pane, so the gap band is already covered and not click/hover-through. This is why // the connected-overlay `offsetY`→in-pane-padding fix (KBQ_CONNECTED_OVERLAY_* / --kbq-connected-overlay-gap, @@ -474,39 +790,52 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl ngAfterContentInit(): void { if (this.closeOnScroll === null) { - this.scrollDispatcher.scrolled().subscribe((scrollable: CdkScrollable | void) => { - if (!scrollable?.getElementRef().nativeElement.classList.contains('kbq-hide-nested-popup')) return; + this.scrollDispatcher + .scrolled() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(this.hideIfNestedPopupScrolledAway); + } + } - const parentRects = scrollable.getElementRef().nativeElement.getBoundingClientRect(); - const childRects = this.elementRef.nativeElement.getBoundingClientRect(); + /** @docs-private */ + show(delay: number = this.enterDelay): void { + this.deliberateOpen = !passiveOpenEvents.includes(this.triggerName); - if (childRects.bottom < parentRects.top || childRects.top > parentRects.bottom) { - this.hide(); - } - }); - } + super.show(delay); } + /** @docs-private */ updateData() { if (!this.instance) return; this.instance.header = this.header; this.instance.content = this.content; - this.instance.context = this.context && { $implicit: this.context }; - this.instance.arrow = this.arrow; + this.instance.context = this.context != null ? { $implicit: this.context } : null; + // `setStickPosition` drops the arrow because a stuck panel no longer points at its trigger; copying + // the input back would resurrect a detached rotated square on the next update. + this.instance.arrow = this.stickToWindow ? false : this.arrow; this.instance.offset = this.offset; this.instance.footer = this.footer; this.instance.hasCloseButton = this.hasCloseButton; this.instance.defaultPaddings = this.defaultPaddings; + this.instance.panelId = this.panelId; + this.instance.ariaLabel = this.ariaLabel; this.instance.updateTrapFocus(this.trigger !== PopUpTriggers.Focus); if (this.isOpen) { - this.updatePosition(true); + this.scheduleReposition(); } } - /** Updates the position of the current popover. */ + /** + * Re-applies the overlay position. + * + * Deliberately narrower than the inherited one, which also rebuilds the closing-action subscription: + * that stream carries a `delay(0)`, so a reposition coalesced into the same task as an outside click — + * which is the task in which that click's change detection writes a popover input — would drop the + * close the click had already scheduled. + */ updatePosition(reapplyPosition: boolean = false) { this.overlayRef = this.createOverlay(); @@ -519,10 +848,12 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl } } + /** @docs-private */ getOverlayHandleComponentType(): Type { return KbqPopoverComponent; } + /** @docs-private */ updateClassMap(newPlacement: string = this.placement) { if (!this.instance) { return; @@ -532,6 +863,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl this.instance.markForCheck(); } + /** @docs-private */ closingActionsForClick() { if (this.hasClickTrigger) { return this.defaultClosingActions(); @@ -540,6 +872,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl return []; } + /** @docs-private */ defaultClosingActions() { return [ this.overlayRef!.backdropClick(), @@ -547,6 +880,7 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl ]; } + /** @docs-private */ closingActions() { // Inner panel scrolling must not trigger closeOnScroll. const scrolled = this.closeOnScroll ? this.scrollDispatcher.ancestorScrolled(this.getNativeElement()) : NEVER; @@ -554,18 +888,89 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger impl return merge(...this.closingActionsForClick(), scrolled); } + /** + * Returns focus to the trigger when the popover is closed while focus is still inside its panel. + * + * Goes through `FocusMonitor` rather than a native `focus()` call: the kbq focus ring is only rendered + * for a monitored origin, and a `program` origin keeps a tooltip on the same element from re-opening. + */ + restoreFocus(): void { + if (!this.overlayRef?.overlayElement?.contains(this.document.activeElement)) return; + + this.focusMonitor.focusVia(this.getNativeElement(), 'program'); + } + + /** + * Closes the popover on a deliberate action — the panel's close button, or `Escape` inside the panel. + * + * `hide()` keeps a hover popover open while the pointer rests on the panel, so that the pointer can + * travel from the trigger to the content; that is exactly where the pointer is when the close button is + * clicked, so a deliberate close answers to `kbqPopoverPreventClose` and to nothing else. + */ + close(): void { + if (this.preventClose) return; + + this.instance?.hide(0); + } + + /** + * Coalesces the position re-apply: a burst of input writes — or a parent change-detection pass that + * recreates the object bound to `kbqPopoverContext` — schedules one reposition per turn instead of one + * per write, each of which costs a forced layout read per candidate position. + */ + private scheduleReposition(): void { + if (this.repositionScheduled) return; + + this.repositionScheduled = true; + + queueMicrotask(() => { + this.repositionScheduled = false; + + if (this.isOpen && this.overlayRef?.hasAttached()) { + this.updatePosition(true); + } + }); + } + + /** + * Drops a closed overlay so the next `show()` rebuilds it. The backdrop and its class are baked into the + * overlay when it is created, and CDK does not re-render them from the config afterwards. + */ + private invalidateOverlay(): void { + if (!this.overlayRef || this.overlayRef.hasAttached()) return; + + this.overlayRef.dispose(); + this.overlayRef = null; + } + + /** Closes the popover once it has scrolled out of its nearest scrollable ancestor. */ private hideIfScrolledOutOfView = () => { - if (!this.scrollable || !this.hideIfNotInViewPort()) return; + if (!this.instance || !this.scrollable || !this.shouldHideIfNotInViewPort()) return; const rect = this.elementRef.nativeElement.getBoundingClientRect(); const containerRect = this.scrollable.getElementRef().nativeElement.getBoundingClientRect(); - if (!( + const intersectsContainer = rect.bottom >= containerRect.top && rect.right >= containerRect.left && rect.top <= containerRect.bottom && - rect.left <= containerRect.right - )) { + rect.left <= containerRect.right; + + if (!intersectsContainer) { + this.hide(); + } + }; + + /** Closes the popover once its trigger has scrolled out of a `.kbq-hide-nested-popup` container. */ + private hideIfNestedPopupScrolledAway = (scrollable: CdkScrollable | void) => { + if (!this.instance) return; + + if (!scrollable?.getElementRef().nativeElement.classList.contains('kbq-hide-nested-popup')) return; + + const parentRects = scrollable.getElementRef().nativeElement.getBoundingClientRect(); + const childRects = this.elementRef.nativeElement.getBoundingClientRect(); + + if (childRects.bottom < parentRects.top || childRects.top > parentRects.bottom) { this.hide(); } }; diff --git a/packages/components/popover/popover.en.md b/packages/components/popover/popover.en.md index 8b819725c8..a1a2415c7b 100644 --- a/packages/components/popover/popover.en.md +++ b/packages/components/popover/popover.en.md @@ -20,10 +20,12 @@ The height of the popover depends on its content. The recommended maximum height #### Configuring padding -To flexibly control popover padding based on its content, reset the default values using `[defaultPaddings]="false"`. Then apply inner padding (padding) directly in the template of the content area: +To flexibly control popover padding based on its content, reset the default values using `[kbqPopoverDefaultPaddings]="false"`. Then apply inner padding (padding) directly in the template of the content area: +The default padding also keeps the focus outline of the first field in the content area from being clipped by the panel, which hides its overflow. After resetting it, reserve that space in your own padding. + #### Hiding the arrow The arrow extending from the popover can be hidden, which allows the popover to be positioned closer to the trigger element. @@ -62,6 +64,8 @@ The popover can be configured to close when the page scrolls; which behavior to +The behavior is controlled by `kbqPopoverCloseOnScroll`, which has three states. Left unbound, the popover survives a scroll but still closes once it has scrolled out of a container marked with the `kbq-hide-nested-popup` class. Set to `true`, it closes on any scroll. Set to `false`, it never closes on a scroll — not even in such a container. + ### Usage examples The popover can be used without a header and footer — these are optional elements. @@ -82,6 +86,38 @@ The close button can be placed in the header or in the top right corner when the +#### Confirming an action + +`[kbqPopoverConfirm]` is a ready-made variant of the popover that asks the user to confirm an action. It has neither a header nor a footer: the panel holds the question and a single confirming button, emits `confirm` when that button is pressed, and closes itself. + +```html + +``` + +Both strings are optional and can be set per trigger: + +```html + +``` + +Left unbound, the question and the button caption come from the `popoverConfirm` section of the active locale, so an application that switched its locale gets the translated wording without configuring anything. To override the wording for a whole application — or for any part of it — provide `KBQ_POPOVER_CONFIRM_TEXT` and `KBQ_POPOVER_CONFIRM_BUTTON_TEXT`: an input beats the provider, and the provider beats the locale. + +### Opening and closing + +By default the popover opens on a click and on `Enter`/`Space`, and closes on a click outside it, on `Esc`, or through the close button. Other triggers are selected with `kbqTrigger`: `hover`, `focus`, `manual`, or a comma-separated combination of them. + +`kbqEnterDelay` and `kbqLeaveDelay` set the delays before opening and closing, in milliseconds. Both default to `0`, with one exception: a popover whose trigger includes `hover` closes after 500 ms unless `kbqLeaveDelay` is bound explicitly, which keeps the panel reachable while the pointer travels towards it. Bind `kbqLeaveDelay="0"` to opt out. + +`kbqTrigger`, `kbqEnterDelay` and `kbqLeaveDelay` are shared with the [tooltip](/en/components/tooltip) directive, so binding them on an element that carries both reconfigures both. + ### Layering By default, the popover appears above the horizontal [Navbar](/en/components/navbar) and [Topbar](/en/components/topbar), and above adjacent elements in other cases. @@ -90,6 +126,12 @@ To prevent the popover from overlapping a required element during scrolling and +### Accessibility + +The panel is exposed as a dialog and is labelled by its header. A popover without a header has nothing to take its name from, so give it one with `kbqPopoverAriaLabel`: until the panel has a name it is not announced as a dialog at all, because an unnamed dialog tells the user nothing. + +Opening the popover by click, by keyboard or programmatically moves focus into the panel and keeps it there while it is open; every closing path returns focus to the trigger. A popover opened by hover or focus does not take focus, because it is dismissed by the pointer leaving it. + ### Recommendations When a short text without interactive elements needs to be shown in a popup, use a [tooltip](/en/components/tooltip). diff --git a/packages/components/popover/popover.module.ts b/packages/components/popover/popover.module.ts index 37441cfaf9..a604f442de 100644 --- a/packages/components/popover/popover.module.ts +++ b/packages/components/popover/popover.module.ts @@ -1,41 +1,9 @@ -import { - A11yModule, - ConfigurableFocusTrapFactory, - FOCUS_TRAP_INERT_STRATEGY, - FocusTrapFactory -} from '@angular/cdk/a11y'; -import { CdkObserveContent } from '@angular/cdk/observers'; -import { OverlayModule } from '@angular/cdk/overlay'; -import { NgTemplateOutlet } from '@angular/common'; import { NgModule } from '@angular/core'; -import { KbqButtonModule } from '@koobiq/components/button'; -import { EmptyFocusTrapStrategy } from '@koobiq/components/core'; -import { KbqIconModule } from '@koobiq/components/icon'; import { KbqPopoverConfirmComponent, KbqPopoverConfirmTrigger } from './popover-confirm.component'; import { KbqPopoverComponent, KbqPopoverTrigger } from './popover.component'; @NgModule({ - imports: [ - OverlayModule, - KbqButtonModule, - A11yModule, - KbqIconModule, - CdkObserveContent, - NgTemplateOutlet, - KbqPopoverComponent, - KbqPopoverTrigger, - KbqPopoverConfirmComponent, - KbqPopoverConfirmTrigger - ], - providers: [ - { provide: FocusTrapFactory, useClass: ConfigurableFocusTrapFactory }, - { provide: FOCUS_TRAP_INERT_STRATEGY, useClass: EmptyFocusTrapStrategy } - ], - exports: [ - KbqPopoverComponent, - KbqPopoverTrigger, - KbqPopoverConfirmComponent, - KbqPopoverConfirmTrigger - ] + imports: [KbqPopoverComponent, KbqPopoverTrigger, KbqPopoverConfirmComponent, KbqPopoverConfirmTrigger], + exports: [KbqPopoverComponent, KbqPopoverTrigger, KbqPopoverConfirmComponent, KbqPopoverConfirmTrigger] }) export class KbqPopoverModule {} diff --git a/packages/components/popover/popover.ru.md b/packages/components/popover/popover.ru.md index 3945cd9cbc..41c9f2fefb 100644 --- a/packages/components/popover/popover.ru.md +++ b/packages/components/popover/popover.ru.md @@ -18,10 +18,12 @@ ### Настройка отступов -Чтобы гибко управлять отступами поповера в зависимости от его контента, сбросите стандартные значения с помощью параметра [defaultPaddings]="false". После этого задавайте внутренние отступы (padding) напрямую в шаблоне контентной части: +Чтобы гибко управлять отступами поповера в зависимости от его контента, сбросите стандартные значения с помощью параметра [kbqPopoverDefaultPaddings]="false". После этого задавайте внутренние отступы (padding) напрямую в шаблоне контентной части: +Стандартный отступ заодно не дает панели, которая скрывает переполнение, обрезать обводку фокуса у первого поля в контентной области. Сбросив его, заложите это место в свои отступы. + ### Скрытие указателя Стрелка, исходящая из поповера, может быть скрыта, что позволяет приблизить сам поповер к триггерному элементу. @@ -60,6 +62,8 @@ +За это отвечает параметр `kbqPopoverCloseOnScroll` с тремя состояниями. Если его не задавать, поповер переживает скролл, но все же закрывается, когда уезжает за пределы контейнера с классом `kbq-hide-nested-popup`. Со значением `true` он закрывается при любом скролле, со значением `false` — не закрывается никогда, даже в таком контейнере. + ### Наслоение По умолчанию поповер показывается поверх горизонтальных [Navbar](/ru/components/navbar) и [Topbar](/ru/components/topbar), в остальных случаях — поверх соседних элементов. @@ -88,6 +92,44 @@ +#### Подтверждение действия + +`[kbqPopoverConfirm]` — готовый вариант поповера, который спрашивает у пользователя подтверждение действия. У него нет ни шапки, ни футера: в панели только вопрос и одна подтверждающая кнопка. По нажатию кнопки поповер отправляет событие `confirm` и закрывается сам. + +```html + +``` + +Обе строки необязательные и задаются параметрами на самом триггере: + +```html + +``` + +Если их не задавать, вопрос и подпись кнопки берутся из секции `popoverConfirm` активной локали, так что приложению со сменной локалью не нужно ничего настраивать. Чтобы переопределить формулировки для всего приложения или его части, объявите провайдеры `KBQ_POPOVER_CONFIRM_TEXT` и `KBQ_POPOVER_CONFIRM_BUTTON_TEXT`: параметр важнее провайдера, а провайдер важнее локали. + +### Открытие и закрытие + +По умолчанию поповер открывается по клику и по клавишам `Enter` и `Space`, а закрывается по клику снаружи, по `Esc` или кнопкой закрытия. Другие триггеры выбираются параметром `kbqTrigger`: `hover`, `focus`, `manual` или их сочетание через запятую. + +Параметры `kbqEnterDelay` и `kbqLeaveDelay` задают задержки перед открытием и закрытием в миллисекундах. Оба по умолчанию равны `0`, кроме одного случая: поповер с триггером `hover` закрывается через 500 мс, если `kbqLeaveDelay` не задан явно, — так панель остается достижимой, пока курсор до нее идет. Чтобы отказаться от задержки, задайте `kbqLeaveDelay="0"`. + +Параметры `kbqTrigger`, `kbqEnterDelay` и `kbqLeaveDelay` общие с директивой [тултипа](/ru/components/tooltip), поэтому на элементе с обеими директивами они перенастраивают и ту, и другую. + +### Доступность + +Панель объявлена диалогом, и ее имя берется из шапки. Поповеру без шапки брать имя неоткуда, поэтому задайте его параметром `kbqPopoverAriaLabel`: пока имени нет, панель диалогом не объявляется — безымянный диалог ничего не сообщает пользователю. + +Открытие по клику, с клавиатуры или программно переводит фокус внутрь панели и удерживает его там, пока поповер открыт; любой путь закрытия возвращает фокус на триггер. Поповер, открытый по наведению или по фокусу, фокус не забирает, потому что закрывается уходом курсора. + ### Рекомендации Когда нужно показать во всплывающем окне короткий текст без интерактивных элементов, то используйте [тултип](/ru/components/tooltip). diff --git a/packages/components/popover/popover.scss b/packages/components/popover/popover.scss index c0e574c025..94a7583019 100644 --- a/packages/components/popover/popover.scss +++ b/packages/components/popover/popover.scss @@ -10,6 +10,12 @@ $arrow-size: 12px; $arrow-offset: calc(($arrow-size - 1px) / -2); $arrow-padding: 24px; +// Stacking order inside a panel. The arrow is a rotated square drawn behind the panel so that only the half +// sticking out of it is visible; the header, footer and close button float above the scrolling content. +$z-index-arrow: -1; +$z-index-panel: 1; +$z-index-above-content: 2; + .kbq-popover { position: relative; @@ -20,7 +26,7 @@ $arrow-padding: 24px; list-style: none; white-space: pre-line; - z-index: 1; + z-index: $z-index-panel; &.kbq-popover_small { width: var(--kbq-popover-size-container-width-small); @@ -73,21 +79,21 @@ $arrow-padding: 24px; } } -.kbq-popover__content .kbq-popover__close-container { - display: flex; - justify-content: flex-end; - height: 0; +.kbq-popover__close-container_without-header .kbq-popover__close { + // Without a header there is no padded strip to sit in, so the button straddles the trailing top corner + // of the panel instead of overlapping the content, which would scroll it out of view. + top: 0; + inset-inline-end: 0; - .kbq-popover__close { - top: auto; - right: auto; - transform: translateY(-50%) translateX(50%); - } + // `transform` has no logical form, so the half-step out of the panel has to be mirrored by hand. + // Left physical it would keep pushing towards the right under `dir="rtl"`, where `inset-inline-end` has + // already moved the button to the left edge — putting it inside the panel, on top of the content. + @include common.rtl('transform', translateY(-50%) translateX(50%), translateY(-50%) translateX(-50%)); } .kbq-popover__header { position: relative; - z-index: 2; + z-index: $z-index-above-content; display: flex; flex-shrink: 0; @@ -106,12 +112,12 @@ $arrow-padding: 24px; } .kbq-popover__header_with-close-button { - padding-right: var(--kbq-size-4xl); + padding-inline-end: var(--kbq-size-4xl); } .kbq-popover__footer { position: relative; - z-index: 2; + z-index: $z-index-above-content; } .kbq-popover__footer.kbq-popover__footer_default-paddings { @@ -121,7 +127,7 @@ $arrow-padding: 24px; .kbq-popover__arrow { position: absolute; - z-index: -1; + z-index: $z-index-arrow; width: $arrow-size; height: $arrow-size; @@ -133,9 +139,9 @@ $arrow-padding: 24px; .kbq-popover__close { position: absolute; - z-index: 2; + z-index: $z-index-above-content; top: var(--kbq-size-s); - right: var(--kbq-size-s); + inset-inline-end: var(--kbq-size-s); border-radius: var(--kbq-size-border-radius); } diff --git a/packages/components/popover/popover.spec.ts b/packages/components/popover/popover.spec.ts index b6c9b515ba..f27bc5c706 100644 --- a/packages/components/popover/popover.spec.ts +++ b/packages/components/popover/popover.spec.ts @@ -1,7 +1,11 @@ import { coerceElement } from '@angular/cdk/coercion'; -import { FlexibleConnectedPositionStrategy, OverlayContainer } from '@angular/cdk/overlay'; +import { + ConnectedOverlayPositionChange, + FlexibleConnectedPositionStrategy, + OverlayContainer +} from '@angular/cdk/overlay'; import { CdkScrollable, ScrollDispatcher } from '@angular/cdk/scrolling'; -import { Component, DebugElement, ElementRef, Provider, Type, inject as inject_1, viewChild } from '@angular/core'; +import { Component, DebugElement, ElementRef, Provider, TemplateRef, Type, viewChild } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, inject, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -9,24 +13,36 @@ import { ARROW_BOTTOM_MARGIN_AND_HALF_HEIGHT, ENTER, ESCAPE, + KBQ_LOCALE_SERVICE, + KbqLocaleService, + KbqPopUpPlacementValues, + KbqStickToWindowPlacementValues, + POSITION_MAP, + POSITION_TO_CSS_MAP, SPACE, createKeyboardEvent, dispatchEvent, dispatchFakeEvent, dispatchKeyboardEvent, - dispatchMouseEvent + dispatchMouseEvent, + enUSLocaleData, + ruRULocaleData } from '@koobiq/components/core'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; +import { axe } from 'jest-axe'; import { Subject, filter } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { TestScheduler } from 'rxjs/testing'; import { KBQ_POPOVER_CONFIRM_BUTTON_TEXT, KBQ_POPOVER_CONFIRM_TEXT } from './popover-confirm.component'; -import { KbqPopoverTrigger } from './popover.component'; +import { KbqPopoverComponent, KbqPopoverTrigger, defaultHoverLeaveDelay } from './popover.component'; import { KbqPopoverModule } from './popover.module'; /** `KbqTooltipTrigger` default enter delay (400 ms) plus a buffer for the deferred show. */ const tooltipEnterDelay = 410; +/** An axe audit walks the whole overlay and needs more than the repo-wide 2s default. */ +const axeTimeout = 15000; + function openAndAssertPopover(componentFixture: ComponentFixture, triggerElement: ElementRef) { dispatchMouseEvent(coerceElement(triggerElement), 'click'); tick(); @@ -40,18 +56,24 @@ function openAndAssertPopover(componentFixture: ComponentFixture, triggerE } describe('KbqPopover', () => { - let fixture: ComponentFixture; - let componentInstance: KbqPopoverTestComponent; + let fixture: ComponentFixture; + let componentInstance: PopoverTestComponent; let debugElement: DebugElement; let overlayContainer: OverlayContainer; let overlayContainerElement: HTMLElement; - let testScheduler: TestScheduler; const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => { TestBed.configureTestingModule({ imports: [component, NoopAnimationsModule], providers: [ - { provide: AsyncScheduler, useValue: testScheduler }, + // The shared pop-up base still polls with `interval(leaveDelay, scheduler)` while a + // hover-triggered pop-up is open, which spins the CPU when `leaveDelay` is 0. Substituting a + // scheduler that never runs keeps the suite off that path; the polling itself is replaced by + // an event-driven timer on the branch that owns `core/pop-up`, and this provider goes with it. + { + provide: AsyncScheduler, + useValue: new TestScheduler((actual, expected) => expect(expected).toEqual(actual)) + }, ...providers ] }); @@ -62,18 +84,27 @@ describe('KbqPopover', () => { return fixture; }; + const readOverlayContainer = () => { + overlayContainer = TestBed.inject(OverlayContainer); + overlayContainerElement = overlayContainer.getContainerElement(); + }; + + /** Settles the whole closing pipeline: the delayed closing action, the hide timeout and the detach. */ + const settleClose = (componentFixture: ComponentFixture) => { + tick(); + componentFixture.detectChanges(); + tick(); + componentFixture.detectChanges(); + }; + describe('Check test cases', () => { beforeEach(() => { - testScheduler = new TestScheduler((act, exp) => expect(exp).toEqual(act)); - fixture = createComponent(KbqPopoverTestComponent); + fixture = createComponent(PopoverTestComponent); componentInstance = fixture.componentInstance; debugElement = fixture.debugElement; - }); - beforeEach(inject([OverlayContainer], (oc: OverlayContainer) => { - overlayContainer = oc; - overlayContainerElement = oc.getContainerElement(); - })); + readOverlayContainer(); + }); afterEach(() => { overlayContainer.ngOnDestroy(); @@ -90,23 +121,42 @@ describe('KbqPopover', () => { fixture.detectChanges(); expect(overlayContainerElement.textContent).toEqual(expectedValue); + // The hover state is tracked by host listeners on the panel component itself, and the synthetic + // events do not bubble — dispatching on the overlay container would never reach them. + const panel = overlayContainerElement.querySelector('kbq-popover-component')!; + dispatchMouseEvent(triggerElement, 'mouseleave'); fixture.detectChanges(); - dispatchMouseEvent(overlayContainerElement, 'mouseenter'); + dispatchMouseEvent(panel, 'mouseenter'); + fixture.detectChanges(); + + // Past both the pending hide and a full watchdog period: the pointer is on the panel, so neither + // may close it. + tick(defaultHoverLeaveDelay * 2); fixture.detectChanges(); expect(overlayContainerElement.textContent).toContain(expectedValue); - // Move out from the tooltip element to hide it - dispatchMouseEvent(overlayContainerElement, 'mouseleave'); - tick(100); + + // Back onto the trigger and away again: the trigger's own `mouseleave` is what schedules the + // hide. Leaving straight from the panel is handled by the pop-up base's hover watchdog, which + // is polling-based here and stubbed out by the scheduler above — it becomes event-driven on the + // branch that owns `core/pop-up`, and this leg can move back to the panel then. + dispatchMouseEvent(panel, 'mouseleave'); fixture.detectChanges(); - tick(); // wait for next tick to hide + dispatchMouseEvent(triggerElement, 'mouseenter'); + fixture.detectChanges(); + dispatchMouseEvent(triggerElement, 'mouseleave'); + tick(defaultHoverLeaveDelay); + fixture.detectChanges(); + tick(defaultHoverLeaveDelay); + fixture.detectChanges(); + expect(overlayContainerElement.textContent).not.toEqual(expectedValue); expect(triggerElement.classList).not.toContain('kbq-active'); })); it('kbqTrigger = manual', fakeAsync(() => { const expectedValue = '_TEST2'; - const triggerElement = componentInstance.test1().nativeElement; + const triggerElement = componentInstance.test2().nativeElement; expect(overlayContainerElement.textContent).not.toEqual(expectedValue); @@ -117,11 +167,12 @@ describe('KbqPopover', () => { componentInstance.popoverVisibility = false; fixture.detectChanges(); - tick(500); // wait for next tick to hide - fixture.detectChanges(); + settleClose(fixture); expect(overlayContainerElement.textContent).not.toEqual(expectedValue); - expect(triggerElement.classList).not.toContain('kbq-active'); + // A manual trigger is not a dialog opener, so it must not advertise one. + expect(triggerElement.getAttribute('aria-haspopup')).toBeNull(); + expect(triggerElement.getAttribute('aria-expanded')).toBeNull(); })); it('kbqTrigger = focus', fakeAsync(() => { @@ -131,12 +182,10 @@ describe('KbqPopover', () => { dispatchFakeEvent(triggerElement, 'focus'); fixture.detectChanges(); expect(overlayContainerElement.textContent).toContain(featureKey); + dispatchFakeEvent(triggerElement, 'blur'); - tick(100); - fixture.detectChanges(); - tick(); // wait for next tick to hide - fixture.detectChanges(); - tick(); // wait for next tick to hide + settleClose(fixture); + expect(overlayContainerElement.textContent).not.toContain(featureKey); expect(triggerElement.classList).not.toContain('kbq-active'); })); @@ -152,6 +201,9 @@ describe('KbqPopover', () => { const header = debugElement.query(By.css('.kbq-popover__header')); expect(header.nativeElement.textContent).toEqual(expectedValue); + + // A hover popover keeps a watchdog interval running for as long as it is open. + fixture.destroy(); })); it('Can set kbqPopoverContent', fakeAsync(() => { @@ -165,6 +217,8 @@ describe('KbqPopover', () => { const content = debugElement.query(By.css('.kbq-popover__content')); expect(content.nativeElement.textContent).toEqual(expectedValue); + + fixture.destroy(); })); it('renders the custom scrollbar on the content', fakeAsync(() => { @@ -191,6 +245,8 @@ describe('KbqPopover', () => { const footer = debugElement.query(By.css('.kbq-popover__footer')); expect(footer.nativeElement.textContent).toEqual(expectedValue); + + fixture.destroy(); })); it('Can set kbqPopoverClass', fakeAsync(() => { @@ -232,6 +288,8 @@ describe('KbqPopover', () => { it('should open popover with keyboard when kbqTrigger = default for elements other than button', fakeAsync(() => { const triggerElement = componentInstance.test8().nativeElement; + expect(triggerElement.tagName).not.toEqual('BUTTON'); + [ENTER, SPACE].forEach((keyCode) => { dispatchKeyboardEvent(triggerElement, 'keydown', keyCode); tick(); @@ -321,9 +379,16 @@ describe('KbqPopover', () => { }); describe('Check popover confirm', () => { - it('Default text is correct', fakeAsync(() => { - const fixture = createComponent(KbqPopoverConfirmTestComponent); + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('Default text comes from the active locale', fakeAsync(() => { + const fixture = createComponent(PopoverConfirmTestComponent); const { componentInstance, debugElement } = fixture; + + readOverlayContainer(); + const triggerElement = componentInstance.test8().nativeElement; dispatchMouseEvent(triggerElement, 'click'); @@ -332,18 +397,50 @@ describe('KbqPopover', () => { const button = debugElement.query(By.css('.kbq-popover-confirm button')); - expect(button.nativeElement.textContent.trim()).toEqual('Да'); + expect(button.nativeElement.textContent.trim()).toEqual(ruRULocaleData.popoverConfirm.confirmButtonText); const confirmText = debugElement.query(By.css('.kbq-popover-confirm .kbq-popover__content div')); - expect(confirmText.nativeElement.textContent).toEqual('Вы уверены, что хотите продолжить?'); + expect(confirmText.nativeElement.textContent).toEqual(ruRULocaleData.popoverConfirm.confirmText); + })); + + it('Default text follows a locale switch made while the panel is open', fakeAsync(() => { + const fixture = createComponent(PopoverConfirmTestComponent, [ + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } + ]); + const { componentInstance, debugElement } = fixture; + + readOverlayContainer(); + + const buttonText = () => + debugElement.query(By.css('.kbq-popover-confirm button')).nativeElement.textContent.trim(); + const confirmText = () => + debugElement.query(By.css('.kbq-popover-confirm .kbq-popover__content div')).nativeElement.textContent; + + dispatchMouseEvent(componentInstance.test8().nativeElement, 'click'); + tick(); + fixture.detectChanges(); + + expect(buttonText()).toEqual(ruRULocaleData.popoverConfirm.confirmButtonText); + expect(confirmText()).toEqual(ruRULocaleData.popoverConfirm.confirmText); + + // Switched with the panel already on screen: `updateData` only runs on input writes, so nothing + // but the trigger's locale effect can carry the new strings into the live panel. + TestBed.inject(KBQ_LOCALE_SERVICE).setLocale('en-US'); + tick(); + fixture.detectChanges(); + + expect(buttonText()).toEqual(enUSLocaleData.popoverConfirm.confirmButtonText); + expect(confirmText()).toEqual(enUSLocaleData.popoverConfirm.confirmText); })); it('Can set confirm text through input', fakeAsync(() => { - const fixture = createComponent(KbqPopoverConfirmTestComponent); + const fixture = createComponent(PopoverConfirmTestComponent); const { componentInstance, debugElement } = fixture; const expectedValue = 'new confirm text'; + readOverlayContainer(); + const triggerElement = componentInstance.test9().nativeElement; dispatchMouseEvent(triggerElement, 'click'); @@ -356,10 +453,12 @@ describe('KbqPopover', () => { })); it('Can set button text through input', fakeAsync(() => { - const fixture = createComponent(KbqPopoverConfirmTestComponent); + const fixture = createComponent(PopoverConfirmTestComponent); const { componentInstance, debugElement } = fixture; const expectedValue = 'new button text'; + readOverlayContainer(); + const triggerElement = componentInstance.test10().nativeElement; dispatchMouseEvent(triggerElement, 'click'); @@ -371,30 +470,77 @@ describe('KbqPopover', () => { expect(button.nativeElement.textContent.trim()).toEqual(expectedValue); })); - it('Click emits confirm', fakeAsync(() => { - const fixture = createComponent(KbqPopoverConfirmTestComponent); + it('Click emits confirm exactly once and closes the panel', fakeAsync(() => { + const fixture = createComponent(PopoverConfirmTestComponent); const { componentInstance, debugElement } = fixture; const onConfirmSpyFn = jest.spyOn(componentInstance, 'onConfirm'); - const triggerElement = componentInstance.test11().nativeElement; + readOverlayContainer(); - dispatchMouseEvent(triggerElement, 'click'); + dispatchMouseEvent(componentInstance.test11().nativeElement, 'click'); tick(); + fixture.detectChanges(); + + // Every input write re-runs `updateData`; the confirm handler must survive that without stacking. + componentInstance.confirmText = 'updated confirm text'; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); const confirmButton = debugElement.query(By.css('.kbq-popover-confirm button')); dispatchMouseEvent(confirmButton.nativeElement, 'click'); - tick(); - fixture.detectChanges(); + settleClose(fixture); + + expect(onConfirmSpyFn).toHaveBeenCalledTimes(1); + expect(overlayContainerElement.querySelector('.kbq-popover-confirm')).toBeFalsy(); + })); + + it('should not throw when a confirm popover is opened by hover', fakeAsync(() => { + const fixture = createComponent(PopoverConfirmTestComponent); + const { componentInstance } = fixture; + + readOverlayContainer(); + + expect(() => { + dispatchMouseEvent(componentInstance.test13().nativeElement, 'mouseenter'); + tick(); + fixture.detectChanges(); + }).not.toThrow(); + + expect(overlayContainerElement.querySelector('.kbq-popover-confirm')).toBeTruthy(); - expect(onConfirmSpyFn).toHaveBeenCalled(); + fixture.destroy(); })); + + it( + 'should have no axe violations while open', + async () => { + const fixture = createComponent(PopoverConfirmTestComponent); + + readOverlayContainer(); + + dispatchMouseEvent(fixture.componentInstance.test8().nativeElement, 'click'); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(await axe(overlayContainerElement)).toHaveNoViolations(); + }, + axeTimeout + ); }); describe('Check popover confirm with providers', () => { + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + it('Provided text is correct', fakeAsync(() => { - const fixture = createComponent(KbqPopoverConfirmWithProvidersTestComponent); + const fixture = createComponent(PopoverConfirmWithProvidersTestComponent); const { componentInstance, debugElement } = fixture; + + readOverlayContainer(); + const triggerElement = componentInstance.test12().nativeElement; dispatchMouseEvent(triggerElement, 'click'); @@ -412,10 +558,16 @@ describe('KbqPopover', () => { }); describe('Overlay offset', () => { + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + it('should add offset for some positions if element is less than arrow margin', fakeAsync(() => { const fixture = createComponent(PopoverSimple); const { componentInstance } = fixture; + readOverlayContainer(); + const rect = ARROW_BOTTOM_MARGIN_AND_HALF_HEIGHT * 2 - 1; componentInstance.triggerElementRef().nativeElement.getBoundingClientRect = () => ({ @@ -438,6 +590,8 @@ describe('KbqPopover', () => { const fixture = createComponent(PopoverSimple); const { componentInstance } = fixture; + readOverlayContainer(); + componentInstance.triggerElementRef().nativeElement.getBoundingClientRect = () => ({ width: 100, height: 100 @@ -456,240 +610,990 @@ describe('KbqPopover', () => { }); describe('with TemplateRef', () => { - let fixture: ComponentFixture; - let componentInstance: KbqPopoverWithTemplateRef; + let templateFixture: ComponentFixture; + let templateInstance: PopoverWithTemplateRef; beforeEach(() => { - testScheduler = new TestScheduler((act, exp) => expect(exp).toEqual(act)); - fixture = createComponent(KbqPopoverWithTemplateRef); - componentInstance = fixture.componentInstance; - debugElement = fixture.debugElement; - }); + templateFixture = createComponent(PopoverWithTemplateRef); + templateInstance = templateFixture.componentInstance; - beforeEach(inject([OverlayContainer], (oc: OverlayContainer) => { - overlayContainer = oc; - overlayContainerElement = oc.getContainerElement(); - })); + readOverlayContainer(); + }); afterEach(() => { overlayContainer.ngOnDestroy(); }); it('context for template', fakeAsync(() => { - const triggerElement = componentInstance.trigger().nativeElement; + const triggerElement = templateInstance.trigger().nativeElement; dispatchMouseEvent(triggerElement, 'mouseenter'); tick(); - fixture.detectChanges(); + templateFixture.detectChanges(); const header = overlayContainerElement.querySelector('.kbq-popover__header')?.textContent; const content = overlayContainerElement.querySelector('.kbq-popover__content')?.textContent; const footer = overlayContainerElement.querySelector('.kbq-popover__footer')?.textContent; - expect(header).toEqual(componentInstance.context.header); - expect(content).toEqual(componentInstance.context.content); - expect(footer).toEqual(componentInstance.context.footer); + expect(header).toEqual(templateInstance.context.header); + expect(content).toEqual(templateInstance.context.content); + expect(footer).toEqual(templateInstance.context.footer); + + templateFixture.destroy(); })); - }); - describe('with a tooltip on the same element', () => { - let fixture: ComponentFixture; - let trigger: HTMLElement; + it('should pass a falsy context to the template', fakeAsync(() => { + templateInstance.context = 0 as never; + templateFixture.detectChanges(); - /** Opens the tooltip by hover and settles its 400 ms enter delay and the deferred reposition. */ - const showTooltip = () => { - dispatchMouseEvent(trigger, 'mouseenter'); - fixture.detectChanges(); - tick(tooltipEnterDelay); - fixture.detectChanges(); + dispatchMouseEvent(templateInstance.trigger().nativeElement, 'mouseenter'); tick(); - fixture.detectChanges(); - }; + templateFixture.detectChanges(); - /** Presses `Escape` inside the open panel, which is what makes the popover restore focus. */ - const pressEscapeInPanel = () => { - const panel = overlayContainerElement.querySelector('.kbq-popover')!; + const popover = templateFixture.debugElement.query(By.directive(KbqPopoverComponent)) + .componentInstance as KbqPopoverComponent; - dispatchEvent(panel, createKeyboardEvent('keydown', ESCAPE, undefined, 'Escape')); - tick(); - fixture.detectChanges(); + expect(popover.context).toEqual({ $implicit: 0 }); + + templateFixture.destroy(); + })); + }); + + describe('closing behavior', () => { + let closingFixture: ComponentFixture; + let closingInstance: PopoverClosingBehavior; + let trigger: HTMLElement; + + const open = () => { + dispatchMouseEvent(trigger, 'click'); tick(); - fixture.detectChanges(); + closingFixture.detectChanges(); }; + const isOpen = () => !!overlayContainerElement.querySelector('.kbq-popover'); + beforeEach(() => { - testScheduler = new TestScheduler((act, exp) => expect(exp).toEqual(act)); - fixture = createComponent(PopoverWithTooltip); - trigger = fixture.componentInstance.trigger().nativeElement; - }); + closingFixture = createComponent(PopoverClosingBehavior); + closingInstance = closingFixture.componentInstance; + trigger = closingInstance.trigger().nativeElement; - beforeEach(inject([OverlayContainer], (oc: OverlayContainer) => { - overlayContainer = oc; - overlayContainerElement = oc.getContainerElement(); - })); + readOverlayContainer(); + }); afterEach(() => { overlayContainer.ngOnDestroy(); }); - it('should hide the tooltip when the popover opens', fakeAsync(() => { - showTooltip(); + it('should emit kbqPopoverVisibleChange once per state change', fakeAsync(() => { + const spy = jest.spyOn(closingInstance, 'onVisibleChange'); - expect(overlayContainerElement.textContent).toContain('TOOLTIP'); + for (let i = 0; i < 3; i++) { + open(); + dispatchMouseEvent(document.body, 'click'); + settleClose(closingFixture); + } - dispatchMouseEvent(trigger, 'click'); - tick(); - fixture.detectChanges(); + expect(spy).toHaveBeenCalledTimes(6); + expect(spy.mock.calls.map(([value]) => value)).toEqual([true, false, true, false, true, false]); + })); - expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); - expect(overlayContainerElement.textContent).not.toContain('TOOLTIP'); + it('should close on a backdrop click and use the configured backdrop class', fakeAsync(() => { + closingInstance.hasBackdrop = true; + closingInstance.backdropClass = 'test-backdrop'; + closingFixture.detectChanges(); + + open(); + + const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop')!; + + expect(backdrop).toBeTruthy(); + expect(backdrop.classList).toContain('test-backdrop'); + + backdrop.click(); + settleClose(closingFixture); + + expect(isOpen()).toBeFalsy(); })); - it('should not show the tooltip when the popover is closed with Escape', fakeAsync(() => { - showTooltip(); + it('should render a backdrop enabled after the first open', fakeAsync(() => { + open(); + dispatchMouseEvent(document.body, 'click'); + settleClose(closingFixture); - dispatchMouseEvent(trigger, 'click'); - tick(); - fixture.detectChanges(); + closingInstance.hasBackdrop = true; + closingFixture.detectChanges(); - pressEscapeInPanel(); + open(); - expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); - expect(document.activeElement).toBe(trigger); - expect(overlayContainerElement.textContent).not.toContain('TOOLTIP'); + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeTruthy(); })); - it('should release the mute after the popover is closed by an outside click', fakeAsync(() => { - dispatchMouseEvent(trigger, 'click'); - tick(); - fixture.detectChanges(); + it('should keep the popover open while kbqPopoverPreventClose is set', fakeAsync(() => { + open(); + + closingInstance.preventClose = true; + closingFixture.detectChanges(); dispatchMouseEvent(document.body, 'click'); - tick(); - fixture.detectChanges(); - tick(); + settleClose(closingFixture); - expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); - expect(overlayContainerElement.textContent).not.toContain('TOOLTIP'); + expect(isOpen()).toBeTruthy(); - // The mute must be released by this closing path too, not just by Escape (the other test above): - // a genuine mouseleave + mouseenter after the outside click should show the tooltip again. - dispatchMouseEvent(trigger, 'mouseleave'); - tick(); - fixture.detectChanges(); + const panel = overlayContainerElement.querySelector('.kbq-popover')!; - showTooltip(); + dispatchEvent(panel, createKeyboardEvent('keydown', ESCAPE, undefined, 'Escape')); + settleClose(closingFixture); - expect(overlayContainerElement.textContent).toContain('TOOLTIP'); + expect(isOpen()).toBeTruthy(); + + closingInstance.preventClose = false; + closingFixture.detectChanges(); })); - it('should show the tooltip again after the pointer leaves and returns to the trigger', fakeAsync(() => { - dispatchMouseEvent(trigger, 'click'); - tick(); - fixture.detectChanges(); + it('should close when the trigger becomes disabled', fakeAsync(() => { + open(); - pressEscapeInPanel(); + closingInstance.disabled = true; + closingFixture.detectChanges(); + settleClose(closingFixture); - dispatchMouseEvent(trigger, 'mouseleave'); - tick(); - fixture.detectChanges(); + expect(isOpen()).toBeFalsy(); + })); - showTooltip(); + it('should close on touchend', fakeAsync(() => { + open(); - expect(overlayContainerElement.textContent).toContain('TOOLTIP'); + dispatchFakeEvent(trigger, 'touchend'); + settleClose(closingFixture); + + expect(isOpen()).toBeFalsy(); })); - }); -}); -@Component({ - selector: 'popover-simple', - imports: [ - KbqPopoverModule - ], - template: ` - - ` -}) -export class PopoverSimple { - elementRef = inject_1(ElementRef); + it('should close on scroll only when closeOnScroll is enabled', fakeAsync(() => { + closingInstance.closeOnScroll = false; + closingFixture.detectChanges(); - readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); - readonly triggerElementRef = viewChild.required(KbqPopoverTrigger, { read: ElementRef }); -} + open(); + dispatchFakeEvent(document, 'scroll'); + tick(20); + settleClose(closingFixture); -@Component({ - selector: 'popover-close-on-scroll', - imports: [KbqPopoverModule], - template: ` - - ` -}) -class PopoverCloseOnScroll { - readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); -} + expect(isOpen()).toBeTruthy(); -@Component({ - selector: 'kbq-popover-test-component', - imports: [KbqPopoverModule], - template: ` - - - + dispatchMouseEvent(document.body, 'click'); + settleClose(closingFixture); - - - + closingInstance.closeOnScroll = true; + closingFixture.detectChanges(); - - - ` -}) -class KbqPopoverTestComponent { - popoverVisibility: boolean = false; + open(); + dispatchFakeEvent(document, 'scroll'); + tick(20); + settleClose(closingFixture); - readonly test1 = viewChild.required('test1'); - readonly test2 = viewChild.required('test2'); - readonly test3 = viewChild.required('test3'); - readonly test4 = viewChild.required('test4'); - readonly test5 = viewChild.required('test5'); - readonly test6 = viewChild.required('test6'); - readonly test7 = viewChild.required('test7'); - readonly test8 = viewChild.required('test7'); -} + expect(isOpen()).toBeFalsy(); + })); -@Component({ - selector: 'kbq-popover-test-component', - imports: [KbqPopoverModule], - template: ` - - - - - ` -}) -class KbqPopoverConfirmTestComponent { - readonly test8 = viewChild.required('test8'); - readonly test9 = viewChild.required('test9'); - readonly test10 = viewChild.required('test10'); - readonly test11 = viewChild.required('test11'); + it('should restore focus to the trigger when the close button is used', fakeAsync(() => { + closingInstance.hasCloseButton = true; + closingFixture.detectChanges(); - onConfirm() { - return; - } + open(); + + const closeButton = overlayContainerElement.querySelector('.kbq-popover__close button')!; + + expect(closeButton).toBeTruthy(); + + // The CDK focus trap resolves its first tabbable element through element geometry, which jsdom + // never reports — stand in for it, so focus is where a browser would have put it on open. + closeButton.focus(); + + closeButton.click(); + settleClose(closingFixture); + + expect(isOpen()).toBeFalsy(); + expect(document.activeElement).toBe(trigger); + })); + + it('should leave focus alone when it never entered the panel', fakeAsync(() => { + const outside = closingInstance.outside().nativeElement as HTMLElement; + + open(); + outside.focus(); + + dispatchMouseEvent(document.body, 'click'); + settleClose(closingFixture); + + expect(isOpen()).toBeFalsy(); + expect(document.activeElement).toBe(outside); + })); + + it('should leave nothing in the overlay container when destroyed while open', fakeAsync(() => { + open(); + + expect(isOpen()).toBeTruthy(); + + closingFixture.destroy(); + + expect(overlayContainerElement.childNodes.length).toBe(0); + })); + }); + + describe('reposition while closing', () => { + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should still close on an outside click that repositions the panel', fakeAsync(() => { + const fixture = createComponent(PopoverRebuiltContext); + + readOverlayContainer(); + + dispatchMouseEvent(fixture.componentInstance.trigger().nativeElement, 'click'); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); + + // The outside click schedules the close through a `delay(0)`, so it is still pending here. + dispatchMouseEvent(document.body, 'click'); + + // Driven directly rather than through the context binding that queues it: what has to hold is + // that a reposition landing inside that delay does not rebuild the closing-action subscription + // and drop the close with it. The inherited `updatePosition` does exactly that. + fixture.componentInstance.popoverTrigger().updatePosition(true); + + settleClose(fixture); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + })); + }); + + describe('accessibility', () => { + let a11yFixture: ComponentFixture; + let a11yInstance: PopoverClosingBehavior; + let trigger: HTMLElement; + + const open = () => { + dispatchMouseEvent(trigger, 'click'); + tick(); + a11yFixture.detectChanges(); + }; + + beforeEach(() => { + a11yFixture = createComponent(PopoverClosingBehavior); + a11yInstance = a11yFixture.componentInstance; + trigger = a11yInstance.trigger().nativeElement; + + readOverlayContainer(); + }); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should describe the trigger as a dialog opener', fakeAsync(() => { + expect(trigger.getAttribute('aria-haspopup')).toEqual('dialog'); + expect(trigger.getAttribute('aria-expanded')).toEqual('false'); + expect(trigger.getAttribute('aria-controls')).toBeNull(); + + open(); + + const panel = overlayContainerElement.querySelector('.kbq-popover')!; + + expect(trigger.getAttribute('aria-expanded')).toEqual('true'); + expect(trigger.getAttribute('aria-controls')).toEqual(panel.id); + expect(panel.id).toBeTruthy(); + + dispatchMouseEvent(document.body, 'click'); + settleClose(a11yFixture); + + expect(trigger.getAttribute('aria-expanded')).toEqual('false'); + expect(trigger.getAttribute('aria-controls')).toBeNull(); + })); + + it('should label the panel by its header', fakeAsync(() => { + open(); + + const panel = overlayContainerElement.querySelector('.kbq-popover')!; + const header = overlayContainerElement.querySelector('.kbq-popover__header-text')!; + + expect(panel.getAttribute('role')).toEqual('dialog'); + expect(panel.getAttribute('aria-labelledby')).toEqual(header.id); + expect(header.id).toBeTruthy(); + })); + + it('should label a header-less panel with kbqPopoverAriaLabel', fakeAsync(() => { + a11yInstance.header = ''; + a11yInstance.ariaLabel = 'ARIA LABEL'; + a11yFixture.detectChanges(); + + open(); + + const panel = overlayContainerElement.querySelector('.kbq-popover')!; + + expect(panel.getAttribute('aria-labelledby')).toBeNull(); + expect(panel.getAttribute('aria-label')).toEqual('ARIA LABEL'); + })); + + it('should fall back to kbqPopoverAriaLabel when the header is a template', fakeAsync(() => { + // A template header renders arbitrary markup with no text node to point `aria-labelledby` at. + a11yInstance.header = a11yInstance.templateHeader(); + a11yInstance.ariaLabel = 'ARIA LABEL'; + a11yFixture.detectChanges(); + + open(); + + const panel = overlayContainerElement.querySelector('.kbq-popover')!; + + expect(panel.querySelector('.kbq-popover__header')!.textContent).toContain('TEMPLATE HEADER'); + expect(panel.getAttribute('aria-labelledby')).toBeNull(); + expect(panel.getAttribute('aria-label')).toEqual('ARIA LABEL'); + })); + + it('should arm the focus trap and claim focus for a click open', fakeAsync(() => { + open(); + + const panel = a11yFixture.debugElement.query(By.directive(KbqPopoverComponent)) + .componentInstance as KbqPopoverComponent; + + expect(panel.isTrapFocus).toBe(true); + // Whether focus actually lands inside the panel depends on the CDK trap finding a tabbable + // element by geometry, which jsdom has none of; the decision to claim it is what popover owns, + // and the landing is covered by the Playwright suite. + expect(a11yInstance.popoverTrigger().capturesFocusOnOpen).toBe(true); + })); + + it( + 'should have no axe violations while open', + async () => { + a11yInstance.hasCloseButton = true; + a11yFixture.detectChanges(); + + dispatchMouseEvent(trigger, 'click'); + await a11yFixture.whenStable(); + a11yFixture.detectChanges(); + + expect(await axe(overlayContainerElement)).toHaveNoViolations(); + }, + axeTimeout + ); + + it( + 'should have no axe violations when nothing names the panel', + async () => { + // The shipped examples are mostly header-less and none of them binds `kbqPopoverAriaLabel`, + // so this is the configuration a consumer lands in by default. + a11yInstance.header = ''; + a11yInstance.hasCloseButton = true; + a11yFixture.detectChanges(); + + dispatchMouseEvent(trigger, 'click'); + await a11yFixture.whenStable(); + a11yFixture.detectChanges(); + + // Nothing can name the panel, so it must not claim to be a dialog: `aria-dialog-name` fails + // an unnamed one, and a screen reader announces it as "dialog" and nothing more. + expect(overlayContainerElement.querySelector('.kbq-popover')!.getAttribute('role')).toBeNull(); + expect(await axe(overlayContainerElement)).toHaveNoViolations(); + }, + axeTimeout + ); + }); + + describe('hover trigger', () => { + let hoverFixture: ComponentFixture; + + /** Walks the pointer off the trigger and onto the open panel, the way a user reaching its content does. */ + const movePointerOntoPanel = (trigger: HTMLElement) => { + // The hover state is tracked by host listeners on the panel component itself, and the synthetic + // events do not bubble — dispatching on the overlay container would never reach them. + dispatchMouseEvent(trigger, 'mouseleave'); + hoverFixture.detectChanges(); + dispatchMouseEvent(overlayContainerElement.querySelector('kbq-popover-component')!, 'mouseenter'); + hoverFixture.detectChanges(); + }; + + beforeEach(() => { + hoverFixture = createComponent(PopoverHoverBehavior); + + readOverlayContainer(); + }); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should not steal keyboard focus', fakeAsync(() => { + const outside = hoverFixture.componentInstance.outside().nativeElement as HTMLElement; + + outside.focus(); + + dispatchMouseEvent(hoverFixture.componentInstance.trigger().nativeElement, 'mouseenter'); + tick(); + hoverFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); + expect(hoverFixture.componentInstance.popoverTrigger().capturesFocusOnOpen).toBe(false); + expect(document.activeElement).toBe(outside); + + hoverFixture.destroy(); + })); + + it('should claim focus for a keyboard open that follows a hover', fakeAsync(() => { + const trigger = hoverFixture.componentInstance.keyboard().nativeElement as HTMLElement; + + // One hover is enough to leave the last recorded trigger event at `mouseleave`, and the shared + // base records nothing for its own keyboard opener. + dispatchMouseEvent(trigger, 'mouseenter'); + tick(); + hoverFixture.detectChanges(); + dispatchMouseEvent(trigger, 'mouseleave'); + tick(defaultHoverLeaveDelay); + tick(defaultHoverLeaveDelay); + hoverFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + + dispatchKeyboardEvent(trigger, 'keydown', ENTER); + tick(); + hoverFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); + expect(hoverFixture.componentInstance.keyboardTrigger().capturesFocusOnOpen).toBe(true); + + hoverFixture.destroy(); + })); + + it('should hold the panel open for the default leave delay', fakeAsync(() => { + const trigger = hoverFixture.componentInstance.trigger().nativeElement as HTMLElement; + + dispatchMouseEvent(trigger, 'mouseenter'); + tick(); + hoverFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); + + // The pointer leaves the trigger without landing on the panel, so the delay is the only thing + // keeping the popover on screen while the pointer travels towards it. + dispatchMouseEvent(trigger, 'mouseleave'); + tick(defaultHoverLeaveDelay - 1); + hoverFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); + + // The scheduled hide hands the same delay on to the panel, so the second period is the panel's. + tick(1); + tick(defaultHoverLeaveDelay); + hoverFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + })); + + it('should honour an explicit kbqLeaveDelay of zero', fakeAsync(() => { + const trigger = hoverFixture.componentInstance.instantTrigger().nativeElement as HTMLElement; + + dispatchMouseEvent(trigger, 'mouseenter'); + tick(); + hoverFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); + + dispatchMouseEvent(trigger, 'mouseleave'); + settleClose(hoverFixture); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + })); + + it('should close on the close button while the pointer rests on the panel', fakeAsync(() => { + const trigger = hoverFixture.componentInstance.closable().nativeElement as HTMLElement; + + dispatchMouseEvent(trigger, 'mouseenter'); + tick(); + hoverFixture.detectChanges(); + + // Reaching the close button means the pointer has left the trigger and landed on the panel — + // the one state the shared `hide()` refuses to close in, so that a hover popover survives the + // trip between the two. + movePointerOntoPanel(trigger); + + overlayContainerElement.querySelector('.kbq-popover__close button')!.click(); + settleClose(hoverFixture); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + + // Drain the hide the trigger's own `mouseleave` scheduled; it is a no-op now that the panel is gone. + tick(defaultHoverLeaveDelay); + })); + + it('should close on Escape while the pointer rests on the panel', fakeAsync(() => { + const trigger = hoverFixture.componentInstance.closable().nativeElement as HTMLElement; + + dispatchMouseEvent(trigger, 'mouseenter'); + tick(); + hoverFixture.detectChanges(); + + movePointerOntoPanel(trigger); + + dispatchEvent( + overlayContainerElement.querySelector('.kbq-popover')!, + createKeyboardEvent('keydown', ESCAPE, undefined, 'Escape') + ); + settleClose(hoverFixture); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + + tick(defaultHoverLeaveDelay); + })); + }); + + describe('input aliases', () => { + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should treat a bare hasCloseButton attribute as true', fakeAsync(() => { + const fixture = createComponent(PopoverInputAliases); + + readOverlayContainer(); + + dispatchMouseEvent(fixture.componentInstance.bare().nativeElement, 'click'); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover__close')).toBeTruthy(); + })); + + it('should treat hasCloseButton="false" as false', fakeAsync(() => { + const fixture = createComponent(PopoverInputAliases); + + readOverlayContainer(); + + dispatchMouseEvent(fixture.componentInstance.stringFalse().nativeElement, 'click'); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover__close')).toBeFalsy(); + })); + + it('should accept the prefixed aliases of the legacy inputs', fakeAsync(() => { + const fixture = createComponent(PopoverInputAliases); + + readOverlayContainer(); + + dispatchMouseEvent(fixture.componentInstance.prefixed().nativeElement, 'click'); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover__close')).toBeTruthy(); + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeTruthy(); + expect( + overlayContainerElement + .querySelector('.kbq-popover__content')! + .classList.contains('kbq-popover__content_default-paddings') + ).toBeFalsy(); + })); + }); + + describe('placement and positioning', () => { + let placementFixture: ComponentFixture; + let placementInstance: PopoverPlacement; + + const open = () => { + dispatchMouseEvent(placementInstance.trigger().nativeElement, 'click'); + tick(); + placementFixture.detectChanges(); + }; + + const close = () => { + dispatchMouseEvent(document.body, 'click'); + settleClose(placementFixture); + }; + + const panel = () => overlayContainerElement.querySelector('.kbq-popover'); + + const positions = () => + ( + placementInstance.popoverTrigger().createOverlay().getConfig() + .positionStrategy as FlexibleConnectedPositionStrategy + ).positions; + + beforeEach(() => { + placementFixture = createComponent(PopoverPlacement); + placementInstance = placementFixture.componentInstance; + + readOverlayContainer(); + }); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should resolve every placement to its own panel class', fakeAsync(() => { + const placements = Object.entries(POSITION_TO_CSS_MAP) as [KbqPopUpPlacementValues, string][]; + + expect(placements).toHaveLength(12); + + for (const [placement, cssName] of placements) { + placementInstance.placement = placement; + placementFixture.detectChanges(); + + open(); + + expect(panel()!.classList).toContain(`kbq-popover_placement-${cssName}`); + + close(); + } + })); + + it('should emit the placement resolved by the position strategy', fakeAsync(() => { + const spy = jest.spyOn(placementInstance, 'onPlacementChange'); + + open(); + + // jsdom has no layout, so the strategy never flips on its own — feed it the connection pair the + // browser would have resolved instead. + placementInstance.popoverTrigger().onPositionChange({ + connectionPair: POSITION_MAP.rightTop + } as ConnectedOverlayPositionChange); + placementFixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith('rightTop'); + expect(panel()!.classList).toContain('kbq-popover_placement-right-top'); + })); + + it('should try only the prioritised placements', fakeAsync(() => { + placementInstance.placementPriority = ['bottom', 'top']; + placementFixture.detectChanges(); + + open(); + + expect(positions()).toHaveLength(2); + expect(positions()[0]).toMatchObject(POSITION_MAP.bottom); + })); + + it('should not offset the positions when the arrow is hidden', fakeAsync(() => { + placementInstance.arrow = false; + placementFixture.detectChanges(); + + open(); + + expect(positions().some((pos) => 'offsetX' in pos || 'offsetY' in pos)).toBeFalsy(); + })); + + it('should keep the arrow dropped while stuck to the window', fakeAsync(() => { + placementInstance.stickToWindow = 'right'; + placementFixture.detectChanges(); + + open(); + + expect(panel()!.classList).toContain('kbq-popover_arrowless'); + expect(overlayContainerElement.querySelector('.kbq-popover__arrow')).toBeFalsy(); + + // Every input write re-runs `updateData`, which used to copy the `arrow` input back over the + // stick-driven decision and resurrect a rotated square detached from the trigger. + placementInstance.content = 'UPDATED'; + placementFixture.detectChanges(); + tick(); + placementFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover__arrow')).toBeFalsy(); + })); + }); + + describe('placement and size fallbacks', () => { + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should warn and fall back to the top placement on an unknown value', fakeAsync(() => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const fixture = createComponent(PopoverFallbacks); + + readOverlayContainer(); + + dispatchMouseEvent(fixture.componentInstance.badPlacement().nativeElement, 'click'); + tick(); + fixture.detectChanges(); + + expect(warn).toHaveBeenCalled(); + expect(overlayContainerElement.querySelector('.kbq-popover_placement-top')).toBeTruthy(); + })); + + it('should warn and fall back to the medium size on an unknown value', fakeAsync(() => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const fixture = createComponent(PopoverFallbacks); + + readOverlayContainer(); + + dispatchMouseEvent(fixture.componentInstance.badSize().nativeElement, 'click'); + tick(); + fixture.detectChanges(); + + expect(warn).toHaveBeenCalled(); + expect(overlayContainerElement.querySelector('.kbq-popover_medium')).toBeTruthy(); + })); + }); + + describe('leaks', () => { + it('should unsubscribe from the global scroll stream on destroy', () => { + TestBed.configureTestingModule({ imports: [PopoverSimple, NoopAnimationsModule] }); + + const scrolled = new Subject(); + + jest.spyOn(TestBed.inject(ScrollDispatcher), 'scrolled').mockReturnValue(scrolled); + + const fixture = TestBed.createComponent(PopoverSimple); + + fixture.detectChanges(); + + expect(scrolled.observed).toBe(true); + + fixture.destroy(); + + expect(scrolled.observed).toBe(false); + }); + + it('should not measure anything on scroll while closed', () => { + TestBed.configureTestingModule({ imports: [PopoverSimple, NoopAnimationsModule] }); + + const scrolled = new Subject(); + + jest.spyOn(TestBed.inject(ScrollDispatcher), 'scrolled').mockReturnValue(scrolled); + + const fixture = TestBed.createComponent(PopoverSimple); + + fixture.detectChanges(); + + const measure = jest.spyOn(Element.prototype, 'getBoundingClientRect'); + const container = document.createElement('div'); + + // A plain document scroll (`scrolled.next()`) never reaches the layout reads at all — the handler + // bails on the missing container long before them. Emitting the one container the popover does + // react to leaves the closed-state guard as the only thing between the scroll and two reflows. + container.classList.add('kbq-hide-nested-popup'); + scrolled.next({ getElementRef: () => new ElementRef(container) } as CdkScrollable); + + expect(measure).not.toHaveBeenCalled(); + + measure.mockRestore(); + fixture.destroy(); + }); + }); + + describe('with a tooltip on the same element', () => { + let tooltipFixture: ComponentFixture; + let trigger: HTMLElement; + + /** Opens the tooltip by hover and settles its 400 ms enter delay and the deferred reposition. */ + const showTooltip = () => { + dispatchMouseEvent(trigger, 'mouseenter'); + tooltipFixture.detectChanges(); + tick(tooltipEnterDelay); + tooltipFixture.detectChanges(); + tick(); + tooltipFixture.detectChanges(); + }; + + /** Presses `Escape` inside the open panel, which is what makes the popover restore focus. */ + const pressEscapeInPanel = () => { + const panel = overlayContainerElement.querySelector('.kbq-popover')!; + + // The CDK focus trap picks its first tabbable element by geometry, which jsdom never reports — + // put focus where a browser would have put it on open, so there is something to restore from. + panel.querySelector('button')?.focus(); + + dispatchEvent(panel, createKeyboardEvent('keydown', ESCAPE, undefined, 'Escape')); + tick(); + tooltipFixture.detectChanges(); + tick(); + tooltipFixture.detectChanges(); + }; + + beforeEach(() => { + tooltipFixture = createComponent(PopoverWithTooltip); + trigger = tooltipFixture.componentInstance.trigger().nativeElement; + + readOverlayContainer(); + }); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should hide the tooltip when the popover opens', fakeAsync(() => { + showTooltip(); + + expect(overlayContainerElement.textContent).toContain('TOOLTIP'); + + dispatchMouseEvent(trigger, 'click'); + tick(); + tooltipFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeTruthy(); + expect(overlayContainerElement.textContent).not.toContain('TOOLTIP'); + })); + + it('should not show the tooltip when the popover is closed with Escape', fakeAsync(() => { + showTooltip(); + + dispatchMouseEvent(trigger, 'click'); + tick(); + tooltipFixture.detectChanges(); + + pressEscapeInPanel(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + expect(document.activeElement).toBe(trigger); + expect(overlayContainerElement.textContent).not.toContain('TOOLTIP'); + })); + + it('should release the mute after the popover is closed by an outside click', fakeAsync(() => { + dispatchMouseEvent(trigger, 'click'); + tick(); + tooltipFixture.detectChanges(); + + dispatchMouseEvent(document.body, 'click'); + tick(); + tooltipFixture.detectChanges(); + tick(); + + expect(overlayContainerElement.querySelector('.kbq-popover')).toBeFalsy(); + expect(overlayContainerElement.textContent).not.toContain('TOOLTIP'); + + // The mute must be released by this closing path too, not just by Escape (the other test above): + // a genuine mouseleave + mouseenter after the outside click should show the tooltip again. + dispatchMouseEvent(trigger, 'mouseleave'); + tick(); + tooltipFixture.detectChanges(); + + showTooltip(); + + expect(overlayContainerElement.textContent).toContain('TOOLTIP'); + })); + + it('should show the tooltip again after the pointer leaves and returns to the trigger', fakeAsync(() => { + dispatchMouseEvent(trigger, 'click'); + tick(); + tooltipFixture.detectChanges(); + + pressEscapeInPanel(); + + dispatchMouseEvent(trigger, 'mouseleave'); + tick(); + tooltipFixture.detectChanges(); + + showTooltip(); + + expect(overlayContainerElement.textContent).toContain('TOOLTIP'); + })); + }); + + describe('panel rendered without a trigger', () => { + // `KbqPopoverComponent` is exported, so a consumer can render the panel on its own. The trigger is + // what normally closes it, and reaching for one that was never assigned used to throw on the first + // Escape. + it('should hide itself on escape rather than throw', fakeAsync(() => { + const panelFixture = createComponent(KbqPopoverComponent); + + expect(panelFixture.componentInstance.trigger).toBeUndefined(); + expect(() => panelFixture.componentInstance.onEscape()).not.toThrow(); + + tick(); + })); + }); +}); + +@Component({ + selector: 'popover-simple', + imports: [KbqPopoverModule], + template: ` + + ` +}) +class PopoverSimple { + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); + readonly triggerElementRef = viewChild.required(KbqPopoverTrigger, { read: ElementRef }); +} + +@Component({ + selector: 'popover-close-on-scroll', + imports: [KbqPopoverModule], + template: ` + + ` +}) +class PopoverCloseOnScroll { + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); +} + +@Component({ + selector: 'popover-test-component', + imports: [KbqPopoverModule], + template: ` + + + + + + + + + + +
_TEST8
+ ` +}) +class PopoverTestComponent { + popoverVisibility: boolean = false; + + readonly test1 = viewChild.required('test1'); + readonly test2 = viewChild.required('test2'); + readonly test3 = viewChild.required('test3'); + readonly test4 = viewChild.required('test4'); + readonly test5 = viewChild.required('test5'); + readonly test6 = viewChild.required('test6'); + readonly test7 = viewChild.required('test7'); + readonly test8 = viewChild.required('test8'); +} + +@Component({ + selector: 'popover-confirm-test-component', + imports: [KbqPopoverModule], + template: ` + + + + + + ` +}) +class PopoverConfirmTestComponent { + confirmText = 'initial confirm text'; + + readonly test8 = viewChild.required('test8'); + readonly test9 = viewChild.required('test9'); + readonly test10 = viewChild.required('test10'); + readonly test11 = viewChild.required('test11'); + readonly test13 = viewChild.required('test13'); + + onConfirm() { + return; + } } @Component({ - selector: 'kbq-popover-test-with-providers-component', + selector: 'popover-confirm-with-providers-test-component', imports: [KbqPopoverModule], template: ` @@ -699,12 +1603,12 @@ class KbqPopoverConfirmTestComponent { { provide: KBQ_POPOVER_CONFIRM_BUTTON_TEXT, useValue: 'provided button text' } ] }) -class KbqPopoverConfirmWithProvidersTestComponent { +class PopoverConfirmWithProvidersTestComponent { readonly test12 = viewChild.required('test12'); } @Component({ - selector: 'kbq-popover-wih-template-ref', + selector: 'popover-with-template-ref', imports: [KbqPopoverModule], template: ` {{ ctx.header }} @@ -723,18 +1627,193 @@ class KbqPopoverConfirmWithProvidersTestComponent { ` }) -class KbqPopoverWithTemplateRef { +class PopoverWithTemplateRef { readonly trigger = viewChild.required('trigger'); context = { header: 'header', content: 'content', footer: 'footer' }; } +@Component({ + selector: 'popover-closing-behavior', + imports: [KbqPopoverModule], + template: ` + TEMPLATE HEADER + + + ` +}) +class PopoverClosingBehavior { + header: string | TemplateRef = 'HEADER'; + ariaLabel: string | undefined; + disabled = false; + preventClose = false; + hasBackdrop = false; + backdropClass = 'cdk-overlay-transparent-backdrop'; + hasCloseButton = false; + closeOnScroll: boolean | null = null; + + readonly outside = viewChild.required('outside'); + readonly trigger = viewChild.required('trigger'); + readonly templateHeader = viewChild.required>('templateHeader'); + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); + + onVisibleChange(_value: boolean) { + return; + } +} + +@Component({ + selector: 'popover-hover-behavior', + imports: [KbqPopoverModule], + template: ` + + + + + + ` +}) +class PopoverHoverBehavior { + readonly outside = viewChild.required('outside'); + readonly trigger = viewChild.required('trigger'); + readonly instantTrigger = viewChild.required('instantTrigger'); + readonly closable = viewChild.required('closable'); + readonly keyboard = viewChild.required('keyboard'); + readonly keyboardTrigger = viewChild.required('keyboard', { read: KbqPopoverTrigger }); + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); +} + +@Component({ + selector: 'popover-rebuilt-context', + imports: [KbqPopoverModule], + template: ` + {{ ctx.text }} + + `, + host: { + '(document:click)': 'rewriteContext()' + } +}) +class PopoverRebuiltContext { + readonly trigger = viewChild.required('trigger'); + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); + + // Rewritten from the document click handler rather than rebuilt on every pass: a binding that returns a + // fresh object each time queues a reposition per pass, and the reposition's own tick then feeds the next + // one — a live-lock that says nothing about the close. What matters here is only that the input is + // written during the very change-detection pass the outside click runs. + context = { text: 'CONTENT' }; + + rewriteContext() { + this.context = { text: 'CONTENT' }; + } +} + +@Component({ + selector: 'popover-input-aliases', + imports: [KbqPopoverModule], + template: ` + + + + ` +}) +class PopoverInputAliases { + readonly bare = viewChild.required('bare'); + readonly stringFalse = viewChild.required('stringFalse'); + readonly prefixed = viewChild.required('prefixed'); +} + +@Component({ + selector: 'popover-placement', + imports: [KbqPopoverModule], + template: ` + + ` +}) +class PopoverPlacement { + content = 'CONTENT'; + placement: KbqPopUpPlacementValues = 'top'; + placementPriority: KbqPopUpPlacementValues[] | null = null; + arrow = true; + stickToWindow: KbqStickToWindowPlacementValues | undefined; + + readonly trigger = viewChild.required('trigger'); + readonly popoverTrigger = viewChild.required(KbqPopoverTrigger); + + onPlacementChange(_value: KbqPopUpPlacementValues) { + return; + } +} + +@Component({ + selector: 'popover-fallbacks', + imports: [KbqPopoverModule], + template: ` + + + ` +}) +class PopoverFallbacks { + readonly badPlacement = viewChild.required('badPlacement'); + readonly badSize = viewChild.required('badSize'); +} + // No `kbqTrigger` binding on purpose: the input alias is shared by both directives, so setting it would // reconfigure the tooltip and the popover at once. Left at the defaults — `hover, focus` and `click, keydown`. @Component({ selector: 'popover-with-tooltip', imports: [KbqPopoverModule, KbqToolTipModule], template: ` - + ` }) class PopoverWithTooltip { diff --git a/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.html b/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.html index 69fc3e5403..e366966f0f 100644 --- a/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.html +++ b/packages/docs-examples/components/list/list-intermediate-state/list-intermediate-state-example.html @@ -10,7 +10,7 @@ [kbqPopoverOffset]="4" [kbqPopoverPlacement]="PopUpPlacements.BottomLeft" [kbqPopoverPlacementPriority]="PopUpPlacements.BottomLeft" - [defaultPaddings]="false" + [kbqPopoverDefaultPaddings]="false" (kbqPopoverVisibleChange)="onVisibleChange($event)" > diff --git a/packages/docs-examples/components/popover/popover-close/popover-close-example.html b/packages/docs-examples/components/popover/popover-close/popover-close-example.html index 98fa09e4e9..b1b5006a01 100644 --- a/packages/docs-examples/components/popover/popover-close/popover-close-example.html +++ b/packages/docs-examples/components/popover/popover-close/popover-close-example.html @@ -85,7 +85,7 @@

Making hybrid‑remote inclusive #kbqSharePopover="kbqPopover" kbq-button kbqPopover - [hasCloseButton]="true" + [kbqPopoverHasCloseButton]="true" [kbqPopoverContent]="customContent" > Крестик в углу с большим заголовком @@ -93,7 +93,7 @@

Making hybrid‑remote inclusive