diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8067d6c..f4a32b9c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- #### Combo, Color picker, Date picker, Date range picker, Tooltip + - `scroll-strategy` attribute: `hide` (default) hides the popover while its anchor is scrolled fully out of view, `scroll` keeps it visible and anchored, `close` closes the component on any scroll. The date pickers ignore it in `dialog` mode. A tooltip with `scroll-strategy="close"` closes even when `sticky`. + +### Changed +- #### Popover + - Popovers now position through native CSS anchor positioning in browsers that support it (Chrome/Edge 133+, Firefox 147+, Safari 26+). Other browsers keep the previous `@floating-ui/dom` behavior, and that module now loads only there. + - **BREAKING**: The `PopoverScrollStrategy` type is now `'scroll' | 'hide' | 'close'`, with `hide` as the default. The `block` value is removed; `block` or any unknown value behaves as `hide`. ## [7.3.0] - 2026-08-26 ### Added diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index 3636a4f0a..452f52ff6 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -25,6 +25,7 @@ import { simulateClick, simulateInput, simulateKeyboard, + simulateScroll, } from '#internals/testing/simulate.spec.js'; import { runValidationContainerTests, @@ -308,6 +309,45 @@ describe('Color picker', () => { }); }); + describe('Scroll strategy', () => { + let container: HTMLDivElement; + + async function openColorPicker() { + picker.open = true; + await elementUpdated(picker); + await nextFrame(); + } + + beforeEach(async () => { + container = await fixture(html` +
+ +
+ `); + picker = container.querySelector(IgcColorPickerComponent.tagName)!; + }); + + it('`scroll` behavior', async () => { + picker.scrollStrategy = 'scroll'; + await openColorPicker(); + await simulateScroll(container, { top: 200 }); + + expect(picker.open).to.be.true; + }); + + it('`close` behavior', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + picker.scrollStrategy = 'close'; + await openColorPicker(); + await simulateScroll(container, { top: 200 }); + + expect(picker.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); + }); + }); + describe('API', () => { beforeEach(async () => { picker = await createDefaultColorPicker(); diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index e1acd6be5..d33055fc6 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -43,7 +43,11 @@ import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; import type IgcSelectItemComponent from '../select/select-item.js'; import IgcSelectComponent from '../select/select.js'; -import type { ColorFormat, ColorPickerMode } from '../types.js'; +import type { + ColorFormat, + ColorPickerMode, + PopoverScrollStrategy, +} from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import IgcVisuallyHiddenComponent from '../visually-hidden/visually-hidden.js'; import { isValidColor, normalizeColor } from './common.js'; @@ -288,6 +292,20 @@ export default class IgcColorPickerComponent extends FormAssociatedRequiredMixin @property({ reflect: true }) public mode: ColorPickerMode = 'default'; + /** + * Sets the behavior of the component when the parent container scrolls. + * + * If the value is `hide`, the component hides while the anchor is fully out + * of view. `hide` is the default value. + * + * If the value is `scroll`, the component stays visible and anchored. + * + * If the value is `close`, the component closes on each scroll. + * @attr scroll-strategy + */ + @property({ attribute: 'scroll-strategy' }) + public scrollStrategy: PopoverScrollStrategy = 'hide'; + /** * Pre-defined color strings. The component renders them as clickable * swatches below the picker controls. A click on a swatch commits its color @@ -1014,7 +1032,12 @@ export default class IgcColorPickerComponent extends FormAssociatedRequiredMixin protected override render(): TemplateResult { return html`
- + ${this._renderAnchor()}${this._renderPicker()} ${ diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 78ca4d4d2..ba567cfb9 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -34,6 +34,7 @@ import { simulateClick, simulateKeyboard, simulatePointerDown, + simulateScroll, } from '#internals/testing/simulate.spec.js'; import { runValidationContainerTests, @@ -1616,6 +1617,45 @@ describe('Combo', () => { }); }); + describe('Scroll strategy', () => { + let container: HTMLDivElement; + + beforeEach(async () => { + container = await fixture(html` +
+ +
+ `); + combo = container.querySelector>( + IgcComboComponent.tagName + )!; + }); + + it('`scroll` behavior', async () => { + combo.scrollStrategy = 'scroll'; + await openComboPopover(combo); + await simulateScroll(container, { top: 200 }); + + expect(combo.open).to.be.true; + }); + + it('`close` behavior', async () => { + const eventSpy = spy(combo, 'emitEvent'); + + combo.scrollStrategy = 'close'; + await openComboPopover(combo); + await simulateScroll(container, { top: 200 }); + + expect(combo.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); + }); + }); + describe('ARIA', () => { beforeEach(async () => { combo = await fixture>( diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 460599443..c000a3486 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -35,6 +35,7 @@ import { addThemingController } from '#theming/theming-controller.js'; import IgcIconComponent from '../icon/icon.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; +import type { PopoverScrollStrategy } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import type { VirtualScrollItemContext } from '../virtualization/types.js'; import IgcVirtualScrollComponent from '../virtualization/virtualization.js'; @@ -361,6 +362,20 @@ export default class IgcComboComponent< return super.locale; } + /** + * Sets the behavior of the component when the parent container scrolls. + * + * If the value is `hide`, the component hides while the anchor is fully out + * of view. `hide` is the default value. + * + * If the value is `scroll`, the component stays visible and anchored. + * + * If the value is `close`, the component closes on each scroll. + * @attr scroll-strategy + */ + @property({ attribute: 'scroll-strategy' }) + public scrollStrategy: PopoverScrollStrategy = 'hide'; + /** * The label of the control. * @attr label @@ -1317,7 +1332,13 @@ export default class IgcComboComponent< protected override render() { return html` - + ${this._renderMainInput()} ${this._renderList()} ${this._renderHelperText()} diff --git a/src/components/date-picker/date-picker.base.ts b/src/components/date-picker/date-picker.base.ts index e8c8f4831..60f777d5b 100644 --- a/src/components/date-picker/date-picker.base.ts +++ b/src/components/date-picker/date-picker.base.ts @@ -42,6 +42,7 @@ import type { ContentOrientation, DateRangeValue, PickerMode, + PopoverScrollStrategy, } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; @@ -278,6 +279,23 @@ export abstract class IgcDatePickerBaseComponent< @property() public mode: PickerMode = 'dropdown'; + /** + * Sets the behavior of the component when the parent container scrolls. + * + * If the value is `hide`, the component hides while the anchor is fully out + * of view. `hide` is the default value. + * + * If the value is `scroll`, the component stays visible and anchored. + * + * If the value is `close`, the component closes on each scroll. + * + * In the `dialog` mode the picker ignores this property, because a scroll + * does not move a modal dialog. + * @attr scroll-strategy + */ + @property({ attribute: 'scroll-strategy' }) + public scrollStrategy: PopoverScrollStrategy = 'hide'; + /** * Makes the control a readonly field. * @attr readonly @@ -790,7 +808,13 @@ export abstract class IgcDatePickerBaseComponent< return this._isDropDown ? html` - + ${this._renderPickerContent(id)} diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 699ef7721..b86aa5256 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -22,6 +22,7 @@ import { simulateClick, simulateInput, simulateKeyboard, + simulateScroll, } from '#internals/testing/simulate.spec.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import { DateRangeType } from '../calendar/types.js'; @@ -644,6 +645,51 @@ describe('Date picker', () => { }); }); + describe('Scroll strategy', () => { + let container: HTMLDivElement; + + beforeEach(async () => { + container = await fixture(html` +
+ +
+ `); + picker = container.querySelector(IgcDatePickerComponent.tagName)!; + }); + + it('`scroll` behavior', async () => { + picker.scrollStrategy = 'scroll'; + await picker.show(); + await simulateScroll(container, { top: 200 }); + + expect(picker.open).to.be.true; + }); + + it('`close` behavior', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + picker.scrollStrategy = 'close'; + await picker.show(); + await simulateScroll(container, { top: 200 }); + + expect(picker.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); + }); + + it('`close` is ignored in dialog mode', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + picker.mode = 'dialog'; + picker.scrollStrategy = 'close'; + await picker.show(); + await simulateScroll(container, { top: 200 }); + + expect(picker.open).to.be.true; + expect(eventSpy).not.to.be.called; + }); + }); + describe('Methods', () => { let input: HTMLInputElement; diff --git a/src/components/date-range-picker/date-range-mask-parser.spec.ts b/src/components/date-range-picker/date-range-mask-parser.spec.ts index 41727e89c..b35b2f787 100644 --- a/src/components/date-range-picker/date-range-mask-parser.spec.ts +++ b/src/components/date-range-picker/date-range-mask-parser.spec.ts @@ -239,12 +239,13 @@ describe('DateRangeMaskParser', () => { const newRange = parser.spinDateRangePart(monthPart, 1, null, true); const today = CalendarDay.today.native; + // Spinning clamps the day to the target month's length (Aug 31 -> Sep 30), + // mirrored here by CalendarDay's month-rollover clamp. + const spun = CalendarDay.today.add('month', 1).native; expect(newRange.start).to.not.be.null; expect(newRange.start!.getFullYear()).to.equal(today.getFullYear()); - expect(newRange.start!.getMonth()).to.equal( - CalendarDay.today.add('month', 1).native.getMonth() - ); - expect(newRange.start!.getDate()).to.equal(today.getDate()); + expect(newRange.start!.getMonth()).to.equal(spun.getMonth()); + expect(newRange.start!.getDate()).to.equal(spun.getDate()); expect(newRange.end).to.be.null; }); }); diff --git a/src/components/date-range-picker/date-range-picker-single.spec.ts b/src/components/date-range-picker/date-range-picker-single.spec.ts index 780406fc6..e9043ec8d 100644 --- a/src/components/date-range-picker/date-range-picker-single.spec.ts +++ b/src/components/date-range-picker/date-range-picker-single.spec.ts @@ -26,6 +26,7 @@ import { simulateClick, simulateInput, simulateKeyboard, + simulateScroll, } from '#internals/testing/simulate.spec.js'; import IgcCalendarComponent from '../calendar/calendar.js'; import type IgcDialogComponent from '../dialog/dialog.js'; @@ -420,6 +421,28 @@ describe('Date range picker - single input', () => { expect(input.value).to.equal('01/15/2025 - 01/15/2026'); }); }); + describe('Scroll strategy', () => { + // Inherited from the picker base class - one end-to-end smoke test. + it('`close` behavior', async () => { + const container = await fixture(html` +
+ +
+ `); + picker = container.querySelector(IgcDateRangePickerComponent.tagName)!; + const eventSpy = spy(picker, 'emitEvent'); + + await picker.show(); + await simulateScroll(container, { top: 200 }); + + expect(picker.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); + }); + }); + describe('Methods', () => { it('should clear the input on invoking clear()', async () => { const eventSpy = spy(picker, 'emitEvent'); diff --git a/src/components/dropdown/dropdown.spec.ts b/src/components/dropdown/dropdown.spec.ts index 9315db857..285dc4ee9 100644 --- a/src/components/dropdown/dropdown.spec.ts +++ b/src/components/dropdown/dropdown.spec.ts @@ -137,6 +137,7 @@ describe('Dropdown', () => { }); it('`scroll` behavior', async () => { + dropDown.scrollStrategy = 'scroll'; await openDropdown(); await simulateScroll(container, { top: 200 }); @@ -154,14 +155,6 @@ describe('Dropdown', () => { expect(eventSpy.firstCall).calledWith('igcClosing'); expect(eventSpy.lastCall).calledWith('igcClosed'); }); - - it('`block behavior`', async () => { - dropDown.scrollStrategy = 'block'; - await openDropdown(); - await simulateScroll(container, { top: 200 }); - - expect(dropDown.open).to.be.true; - }); }); describe('Detached (non-slotted anchor)', () => { diff --git a/src/components/dropdown/dropdown.ts b/src/components/dropdown/dropdown.ts index 9fe9783a4..7d237e20f 100644 --- a/src/components/dropdown/dropdown.ts +++ b/src/components/dropdown/dropdown.ts @@ -19,7 +19,6 @@ import { type MutationControllerParams, } from '#internals/controllers/mutation-observer.js'; import { addRootClickController } from '#internals/controllers/root-click.js'; -import { addRootScrollHandler } from '#internals/controllers/root-scroll.js'; import { blazorAdditionalDependencies } from '#internals/decorators/blazorAdditionalDependencies.js'; import { registerComponent } from '#internals/definitions/register.js'; import { @@ -101,10 +100,6 @@ export default class IgcDropdownComponent extends EventEmitterMixin< private readonly _keyBindings: KeyBindingController; - private readonly _rootScrollController = addRootScrollHandler(this, { - hideCallback: this._handleClosing, - }); - protected override readonly _rootClickController = addRootClickController( this, { @@ -156,11 +151,18 @@ export default class IgcDropdownComponent extends EventEmitterMixin< public placement: PopoverPlacement = 'bottom-start'; /** - * Determines the behavior of the component during scrolling of the parent container. + * Sets the behavior of the component when the parent container scrolls. + * + * If the value is `hide`, the component hides while the anchor is fully out + * of view. `hide` is the default value. + * + * If the value is `scroll`, the component stays visible and anchored. + * + * If the value is `close`, the component closes on each scroll. * @attr scroll-strategy */ @property({ attribute: 'scroll-strategy' }) - public scrollStrategy: PopoverScrollStrategy = 'scroll'; + public scrollStrategy: PopoverScrollStrategy = 'hide'; /** * Whether the component should be flipped to the opposite side of the target once it's about to overflow the visible area. @@ -257,16 +259,9 @@ export default class IgcDropdownComponent extends EventEmitterMixin< return; } - const openChanged = properties.has('open'); - const strategyChanged = properties.has('scrollStrategy'); - - if (openChanged || properties.has('keepOpenOnOutsideClick')) { + if (properties.has('open') || properties.has('keepOpenOnOutsideClick')) { this._rootClickController.update(); } - - if (openChanged || strategyChanged) { - this._rootScrollController.update({ resetListeners: strategyChanged }); - } } protected override async firstUpdated(): Promise { @@ -610,7 +605,8 @@ export default class IgcDropdownComponent extends EventEmitterMixin< .anchor=${this._target} .offset=${this.distance} .placement=${this.placement} - shift + .scrollStrategy=${this.scrollStrategy} + @igcPopoverScrollClose=${this._handleClosing} > { - before(() => { - defineComponents(IgcPopoverComponent); - }); - +function definePositioningSuites(mode: PositionMode) { describe('Slotted anchor element', async () => { let popover: IgcPopoverComponent; let anchor: HTMLButtonElement; @@ -190,9 +193,7 @@ describe('Popover', () => { describe('With initial open state', () => { beforeEach(async () => { const root = await fixture(createNonSlottedPopover(true)); - popover = root.querySelector( - IgcPopoverComponent.tagName - ) as IgcPopoverComponent; + popover = queryPopover(root); anchor = root.querySelector('#btn') as HTMLButtonElement; }); @@ -220,9 +221,7 @@ describe('Popover', () => { describe('With initial closed state', () => { beforeEach(async () => { const root = await fixture(createNonSlottedPopover()); - popover = root.querySelector( - IgcPopoverComponent.tagName - ) as IgcPopoverComponent; + popover = queryPopover(root); anchor = root.querySelector('#btn') as HTMLButtonElement; }); @@ -405,6 +404,22 @@ describe('Popover', () => { expect(isFloaterOpen(popover)).to.be.false; }); + + it('stays open when the anchor is removed and re-inserted in the same task', async () => { + await openAtButton(); + + const parent = anchor.parentElement as HTMLElement; + const sibling = anchor.nextSibling; + + anchor.remove(); + parent.insertBefore(anchor, sibling); + await waitForPaint(popover); + + expect(isFloaterOpen(popover)).to.be.true; + expect(getFloater(popover).getBoundingClientRect().top).to.equal( + anchor.getBoundingClientRect().bottom + ); + }); }); describe('Open state', () => { @@ -437,6 +452,175 @@ describe('Popover', () => { expect(isFloaterOpen(popover)).to.be.true; }); + + it('uses the expected positioning mechanism', async () => { + const floater = getFloater(popover); + + if (mode === 'native') { + expect(floater.hasAttribute('data-anchored')).to.be.true; + expect(floater.style.transform).to.equal(''); + } else { + expect(floater.hasAttribute('data-anchored')).to.be.false; + expect(floater.style.transform).to.not.equal(''); + } + }); + }); + + describe('Placement', () => { + function createPlacedPopover(placement: PopoverPlacement, dir = 'ltr') { + return html` +
+ + +
M
+
+
+ `; + } + + async function placementRects(placement: PopoverPlacement, dir = 'ltr') { + const root = await fixture( + createPlacedPopover(placement, dir) + ); + const popover = queryPopover(root); + await waitForPaint(popover); + + return { + floater: getFloater(popover).getBoundingClientRect(), + anchor: root.querySelector('#btn')!.getBoundingClientRect(), + }; + } + + function centerX(rect: DOMRect) { + return rect.left + rect.width / 2; + } + + function centerY(rect: DOMRect) { + return rect.top + rect.height / 2; + } + + // Main-axis edge and cross-axis alignment relations per placement, + // shared by both strategies. + const MATRIX: Array< + [PopoverPlacement, (f: DOMRect, a: DOMRect) => Array<[number, number]>] + > = [ + [ + 'top', + (f, a) => [ + [f.bottom, a.top], + [centerX(f), centerX(a)], + ], + ], + [ + 'top-start', + (f, a) => [ + [f.bottom, a.top], + [f.left, a.left], + ], + ], + [ + 'top-end', + (f, a) => [ + [f.bottom, a.top], + [f.right, a.right], + ], + ], + [ + 'bottom', + (f, a) => [ + [f.top, a.bottom], + [centerX(f), centerX(a)], + ], + ], + [ + 'bottom-start', + (f, a) => [ + [f.top, a.bottom], + [f.left, a.left], + ], + ], + [ + 'bottom-end', + (f, a) => [ + [f.top, a.bottom], + [f.right, a.right], + ], + ], + [ + 'left', + (f, a) => [ + [f.right, a.left], + [centerY(f), centerY(a)], + ], + ], + [ + 'left-start', + (f, a) => [ + [f.right, a.left], + [f.top, a.top], + ], + ], + [ + 'left-end', + (f, a) => [ + [f.right, a.left], + [f.bottom, a.bottom], + ], + ], + [ + 'right', + (f, a) => [ + [f.left, a.right], + [centerY(f), centerY(a)], + ], + ], + [ + 'right-start', + (f, a) => [ + [f.left, a.right], + [f.top, a.top], + ], + ], + [ + 'right-end', + (f, a) => [ + [f.left, a.right], + [f.bottom, a.bottom], + ], + ], + ]; + + for (const [placement, relations] of MATRIX) { + it(`positions \`${placement}\` against the anchor`, async () => { + const { floater, anchor } = await placementRects(placement); + + for (const [actual, expected] of relations(floater, anchor)) { + expect(actual).to.be.closeTo(expected, 1); + } + }); + } + + it('aligns `-start`/`-end` placements to the inline edges in RTL', async () => { + const start = await placementRects('bottom-start', 'rtl'); + expect(start.floater.right).to.be.closeTo(start.anchor.right, 1); + expect(start.floater.top).to.be.closeTo(start.anchor.bottom, 1); + + const end = await placementRects('bottom-end', 'rtl'); + expect(end.floater.left).to.be.closeTo(end.anchor.left, 1); + }); + + it('keeps `left`/`right` placements physical in RTL', async () => { + const { floater, anchor } = await placementRects('right-start', 'rtl'); + + expect(floater.left).to.be.closeTo(anchor.right, 1); + expect(floater.top).to.be.closeTo(anchor.top, 1); + }); }); describe('Middleware', () => { @@ -450,7 +634,7 @@ describe('Popover', () => { > Show message - +
Message
@@ -546,7 +730,279 @@ describe('Popover', () => { }); }); - describe('Positioning strategy', () => { + describe('Arrow element with flipping', () => { + afterEach(() => { + window.scrollTo(0, 0); + }); + + it('tracks the resolved side of a flipped placement through scrolling', async () => { + const root = await fixture(html` +
+ + +
Message
+
+
+
+ `); + const popover = queryPopover(root); + + popover.arrow = root.querySelector('#arrow') as HTMLElement; + await waitForPaint(popover); + + // Overflows below the viewport - flipped above the anchor. + expect(popover.arrow.part.contains('top')).to.be.true; + + window.scrollTo(0, window.innerHeight); + await waitForPaint(popover); + await nextFrame(); + + // The anchor now sits near the viewport top - back below it. + expect(popover.arrow.part.contains('bottom')).to.be.true; + }); + }); + + describe('Anchor visibility', () => { + // The scroller sits mid-viewport so the popover, which keeps tracking the + // clipped anchor position, stays inside the viewport - the hit-test below + // then reflects only the hidden state, never off-screen geometry. + function createClippedPopover() { + return html` +
+
+
+ +
+
+ +

Message

+
+
+ `; + } + + // The native strongly-hidden state from `position-visibility` is not + // reflected by checkVisibility() or computed styles - hit-testing is the + // one observable signal, and it also covers the fallback's inline + // `visibility: hidden`, since hidden elements are never hit-testable. + function isContentHitTestable(root: HTMLElement): boolean { + const content = root.querySelector('p')!; + const rect = content.getBoundingClientRect(); + const found = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); + return found === content; + } + + it('hides the popover while the anchor is fully clipped and restores it on scroll back', async () => { + const root = await fixture(createClippedPopover()); + const popover = queryPopover(root); + const scroller = root.querySelector('#scroller')!; + + await waitForPaint(popover); + expect(isContentHitTestable(root)).to.be.true; + + // The anchor is now fully above the scroller's visible window. + await simulateScroll(scroller, { top: 150 }); + await waitForPaint(popover); + expect(isContentHitTestable(root)).to.be.false; + + await simulateScroll(scroller, { top: 0 }); + await waitForPaint(popover); + expect(isContentHitTestable(root)).to.be.true; + }); + + it('`scroll` keeps the popover visible while the anchor is fully clipped', async () => { + const root = await fixture(createClippedPopover()); + const popover = queryPopover(root); + const scroller = root.querySelector('#scroller')!; + + popover.scrollStrategy = 'scroll'; + await elementUpdated(popover); + await waitForPaint(popover); + + await simulateScroll(scroller, { top: 150 }); + await waitForPaint(popover); + expect(isContentHitTestable(root)).to.be.true; + }); + + it('switching to `scroll` while hidden restores the popover', async () => { + const root = await fixture(createClippedPopover()); + const popover = queryPopover(root); + const scroller = root.querySelector('#scroller')!; + + await simulateScroll(scroller, { top: 150 }); + await waitForPaint(popover); + expect(isContentHitTestable(root)).to.be.false; + + popover.scrollStrategy = 'scroll'; + await elementUpdated(popover); + await waitForPaint(popover); + expect(isContentHitTestable(root)).to.be.true; + }); + + it('re-opens visible after closing while the anchor was clipped', async () => { + const root = await fixture(createClippedPopover()); + const popover = queryPopover(root); + const scroller = root.querySelector('#scroller')!; + + await simulateScroll(scroller, { top: 150 }); + await waitForPaint(popover); + expect(isContentHitTestable(root)).to.be.false; + + popover.open = false; + await waitForPaint(popover); + + await simulateScroll(scroller, { top: 0 }); + popover.open = true; + await waitForPaint(popover); + + expect(isContentHitTestable(root)).to.be.true; + }); + }); +} + +describe('Popover', () => { + before(() => { + defineComponents(IgcPopoverComponent); + }); + + for (const mode of ['native', 'fallback'] as const) { + const describeMode = + mode === 'native' && !SUPPORTS_ANCHOR_POSITIONING + ? describe.skip + : describe; + + describeMode(`Positioning [${mode}]`, () => { + before(() => { + setPopoverPositionStrategy(mode === 'fallback' ? 'floating' : 'native'); + }); + + after(() => { + setPopoverPositionStrategy(); + }); + + definePositioningSuites(mode); + }); + } + + // Positioning-strategy-agnostic - the popover owns the document scroll + // listener and only notifies; whoever controls `open` closes it. + describe('Scroll strategy', () => { + let popover: IgcPopoverComponent; + let scroller: HTMLElement; + let closeRequests: number; + + beforeEach(async () => { + scroller = await fixture(html` +
+
+ + +

Message

+
+
+
+ `); + popover = queryPopover(scroller); + + closeRequests = 0; + popover.addEventListener('igcPopoverScrollClose', () => { + closeRequests++; + }); + + await waitForPaint(popover); + }); + + it('`hide` (default) and `scroll` emit nothing on ancestor scroll', async () => { + await simulateScroll(scroller, { top: 200 }); + expect(closeRequests).to.equal(0); + expect(popover.open).to.be.true; + + popover.scrollStrategy = 'scroll'; + await elementUpdated(popover); + + await simulateScroll(scroller, { top: 400 }); + expect(closeRequests).to.equal(0); + expect(popover.open).to.be.true; + }); + + it('`igcPopoverScrollClose` does not bubble up the DOM', async () => { + popover.scrollStrategy = 'close'; + await elementUpdated(popover); + + const documentEvents: Event[] = []; + const listener = (event: Event) => documentEvents.push(event); + document.addEventListener('igcPopoverScrollClose', listener); + + try { + await simulateScroll(scroller, { top: 200 }); + } finally { + document.removeEventListener('igcPopoverScrollClose', listener); + } + + // The direct listener on the popover fired, the document one never did. + expect(closeRequests).to.be.greaterThan(0); + expect(documentEvents.length).to.equal(0); + }); + + it('`close` emits `igcPopoverScrollClose` on ancestor scroll while open', async () => { + popover.scrollStrategy = 'close'; + await elementUpdated(popover); + + await simulateScroll(scroller, { top: 200 }); + expect(closeRequests).to.be.greaterThan(0); + + // The popover does not own its open state - closing is up to the host. + expect(popover.open).to.be.true; + }); + + it('stops emitting when the strategy is reset while open', async () => { + popover.scrollStrategy = 'close'; + await elementUpdated(popover); + + await simulateScroll(scroller, { top: 200 }); + expect(closeRequests).to.be.greaterThan(0); + + popover.scrollStrategy = 'scroll'; + await elementUpdated(popover); + const seen = closeRequests; + + await simulateScroll(scroller, { top: 400 }); + expect(closeRequests).to.equal(seen); + }); + + it('does not emit while closed', async () => { + popover.scrollStrategy = 'close'; + popover.open = false; + await elementUpdated(popover); + + await simulateScroll(scroller, { top: 200 }); + expect(closeRequests).to.equal(0); + }); + }); + + // floating-ui specific behavior - the native path has no positioning + // strategy concept (anchor positioning is layout-true under sticky). + describe('Positioning strategy [fallback]', () => { + before(() => { + setPopoverPositionStrategy('floating'); + }); + + after(() => { + setPopoverPositionStrategy(); + }); + function createStickyPopover(level: 'parent' | 'grandparent') { const popover = html` @@ -570,9 +1026,7 @@ describe('Popover', () => { it('uses the `fixed` strategy with a directly sticky ancestor', async () => { const root = await fixture(createStickyPopover('parent')); - const popover = root.querySelector( - IgcPopoverComponent.tagName - ) as IgcPopoverComponent; + const popover = queryPopover(root); await waitForPaint(popover); expect(getFloater(popover).style.position).to.equal('fixed'); @@ -582,9 +1036,7 @@ describe('Popover', () => { const root = await fixture( createStickyPopover('grandparent') ); - const popover = root.querySelector( - IgcPopoverComponent.tagName - ) as IgcPopoverComponent; + const popover = queryPopover(root); await waitForPaint(popover); expect(getFloater(popover).style.position).to.equal('fixed'); @@ -592,9 +1044,7 @@ describe('Popover', () => { it('uses the `absolute` strategy without a sticky ancestor', async () => { const root = await fixture(createNonSlottedPopover(true)); - const popover = root.querySelector( - IgcPopoverComponent.tagName - ) as IgcPopoverComponent; + const popover = queryPopover(root); await waitForPaint(popover); expect(getFloater(popover).style.position).to.equal('absolute'); diff --git a/src/components/popover/popover.ts b/src/components/popover/popover.ts index 007c7f206..46a03cd18 100644 --- a/src/components/popover/popover.ts +++ b/src/components/popover/popover.ts @@ -1,17 +1,3 @@ -import { - arrow, - autoUpdate, - computePosition, - flip, - inline, - limitShift, - type Middleware, - type MiddlewareData, - offset, - type Placement, - shift, - size, -} from '@floating-ui/dom'; import { html, LitElement, type PropertyValues } from 'lit'; import { property, query } from 'lit/decorators.js'; import { @@ -21,14 +7,18 @@ import { } from '#internals/controllers/slot.js'; import { registerComponent } from '#internals/definitions/register.js'; import { firstOf } from '#internals/utils/arrays.js'; -import { - getElementByIdFromRoot, - hasStickyAncestor, - isPopoverOpen, - roundByDPR, - setStyles, -} from '#internals/utils/dom.js'; +import { getElementByIdFromRoot, isPopoverOpen } from '#internals/utils/dom.js'; import { isString } from '#internals/utils/types.js'; +import type { PopoverScrollStrategy } from '../types.js'; +import { FloatingPositionStrategy } from './position/floating.js'; +import { + NativePositionStrategy, + shouldUseNativeAnchorPositioning, +} from './position/native.js'; +import { + type PopoverPositionStrategy, + resolvePlacement, +} from './position/types.js'; import { styles } from './themes/light/popover.base.css.js'; /** @@ -48,16 +38,14 @@ export type PopoverPlacement = | 'left-start' | 'left-end'; -const OPPOSITE_SIDE = { - top: 'bottom', - right: 'left', - bottom: 'top', - left: 'right', -} as const; - -type PopoverSide = keyof typeof OPPOSITE_SIDE; - -const SIDES = Object.keys(OPPOSITE_SIDE) as PopoverSide[]; +/** + * The `scroll` event is not cancelable. A passive listener does not delay the + * scroll, so the listener uses the passive option. + */ +const scrollListenerOptions: AddEventListenerOptions = { + capture: true, + passive: true, +}; /* blazorSuppress */ /** @@ -66,6 +54,11 @@ const SIDES = Object.keys(OPPOSITE_SIDE) as PopoverSide[]; * @slot - Content of the popover. * @slot anchor - The element the popover will be anchored to. * + * @fires igcPopoverScrollClose - The popover emits this event when the document scrolls. + * The popover emits it only if the popover is open and the scroll strategy is `close`. + * The popover does not control its own `open` state. The component that owns that state must close the popover. + * The event does not bubble. Add the listener directly on the popover element. + * * @csspart container - The container wrapping the slotted content in the popover. */ export default class IgcPopoverComponent extends LitElement { @@ -79,20 +72,17 @@ export default class IgcPopoverComponent extends LitElement { //#region Internal properties and state - private _dispose?: ReturnType; private _target?: Element; - private _middleware?: Middleware[]; - private _positionId = 0; + private _positionStrategy?: PopoverPositionStrategy; /** - * The positioning strategy resolved when the popover is opened. The `fixed` - * strategy is used when the anchor has a `position: sticky` ancestor, otherwise - * the default `absolute` strategy is used. Cached here to avoid repeated DOM - * traversals and style reflows on every scroll/resize reposition. + * The anchor that the container currently shows against. * - * Also, time to migrate to CSS Anchor positioning!!! + * The browser binds the implicit anchor only when `showPopover({ source })` + * runs. Therefore the native strategy must hide the container and show it + * again if the anchor changes while the popover is open. */ - private _strategy: 'absolute' | 'fixed' = 'absolute'; + private _shownSource?: Element; private readonly _slots = addSlotController(this, { slots: setSlots('anchor'), @@ -123,13 +113,6 @@ export default class IgcPopoverComponent extends LitElement { @property({ type: Number, attribute: 'arrow-offset' }) public arrowOffset = 0; - /** - * Improves positioning for inline reference elements that span over multiple lines. - * Useful for tooltips or similar components. - */ - @property({ type: Boolean, reflect: true }) - public inline = false; - /** * When enabled this changes the placement of the floating element in order to keep it * in view along the main axis. @@ -162,17 +145,22 @@ export default class IgcPopoverComponent extends LitElement { public sameWidth = false; /** - * When enabled this tries to shift the floating element along the main axis - * keeping it in view, preventing overflow while maintaining the desired placement. - */ - @property({ type: Boolean, reflect: true }) - public shift = false; - - /** - * Virtual padding for the resolved overflow detection offsets in pixels. + * Sets the behavior of the popover when an ancestor scroll container + * scrolls and the popover is open. + * + * If the value is `hide`, the popover hides while the anchor is fully out + * of view. The popover shows again when the anchor returns to view. `hide` + * is the default value. + * + * If the value is `scroll`, the popover stays visible. The popover also + * stays anchored while the anchor is out of view. + * + * If the value is `close`, the popover behaves as for `hide`. The popover + * also emits `igcPopoverScrollClose` for each scroll. The component that + * owns the `open` state must then close the popover. */ - @property({ type: Number, attribute: 'shift-padding' }) - public shiftPadding = 0; + @property({ attribute: 'scroll-strategy' }) + public scrollStrategy: PopoverScrollStrategy = 'hide'; //#endregion @@ -180,16 +168,14 @@ export default class IgcPopoverComponent extends LitElement { protected override update(properties: PropertyValues): void { if (this.hasUpdated) { - this._middleware = undefined; - - if (properties.has('sameWidth') && !this.sameWidth) { - setStyles(this._container, { width: '' }); - } - if (properties.has('open') || properties.has('anchor')) { this._setOpenState(this.open); } else if (this.open) { - this._updatePosition(); + this._positionStrategy?.update(); + } + + if (properties.has('scrollStrategy')) { + this._syncScrollStrategy(this.open); } } @@ -229,12 +215,40 @@ export default class IgcPopoverComponent extends LitElement { private _handleToggle(): void { if (!isPopoverOpen(this._container)) { - this._clearDispose(); + this._positionStrategy?.detach(); } } //#region Internal open state API + private _getPositionStrategy(target: Element): PopoverPositionStrategy { + const useNative = shouldUseNativeAnchorPositioning(target); + const current = this._positionStrategy; + + if (current?.native === useNative) { + return current; + } + + if (current) { + current.detach(); + current.clear(); + } + + const callbacks = { onAnchorRemoved: () => this._handleAnchorRemoved() }; + + this._positionStrategy = useNative + ? new NativePositionStrategy(this, callbacks) + : new FloatingPositionStrategy(this, callbacks); + + return this._positionStrategy; + } + + private _handleAnchorRemoved(): void { + this._target = undefined; + this._positionStrategy?.detach(); + this._setPopoverState(false); + } + /** * An unresolved IDREF keeps the current target, so that an anchor rendered * after this popover is picked up the next time it opens. @@ -251,24 +265,49 @@ export default class IgcPopoverComponent extends LitElement { } private _setOpenState(state: boolean): void { - this._clearDispose(); + this._positionStrategy?.detach(); if (state) { this._target = this._resolveTarget(); if (this._target) { - this._strategy = hasStickyAncestor(this._target) ? 'fixed' : 'absolute'; - this._dispose = autoUpdate( + this._getPositionStrategy(this._target).attach( this._target, - this._container, - this._updatePosition.bind(this) + this._container ); } } this._setPopoverState(state); + this._syncScrollStrategy(state); + } + + /** + * The popover adds one listener on the document. It adds the listener only + * when the popover is open and the scroll strategy is `close`. Every other + * value adds no listener. + * + * The listener reference is stable. Therefore `addEventListener` and + * `removeEventListener` are idempotent, and this method needs no state. + */ + private _syncScrollStrategy(active: boolean): void { + active && this.scrollStrategy === 'close' + ? document.addEventListener( + 'scroll', + this._handleRootScroll, + scrollListenerOptions + ) + : document.removeEventListener( + 'scroll', + this._handleRootScroll, + scrollListenerOptions + ); } + private readonly _handleRootScroll = (): void => { + this.dispatchEvent(new CustomEvent('igcPopoverScrollClose')); + }; + private _setPopoverState(state: boolean): void { const container = this._container; @@ -279,130 +318,42 @@ export default class IgcPopoverComponent extends LitElement { const shouldOpen = state && this._target != null; if (shouldOpen !== isPopoverOpen(container)) { - shouldOpen ? container.showPopover() : container.hidePopover(); + shouldOpen ? this._showPopover() : this._hidePopover(); + } else if ( + shouldOpen && + this._positionStrategy?.native && + this._target !== this._shownSource + ) { + // Change the anchor while the popover stays open. + // The browser combines the `toggle` events of a hide and a show in the + // same task into one open-to-open transition. Therefore + // `_handleToggle` does nothing here. + // Two limitations are known and accepted. A CSS transition on + // `:popover-open` of the container restarts, but the container has no + // such transition today. The focus inside the popover moves out and + // then back. + this._hidePopover(); + this._showPopover(); } } - private _clearDispose(): void { - this._dispose?.(); - this._dispose = undefined; - } - - //#endregion - - //#region Internal position API - - private get _placement(): PopoverPlacement { - return this.placement ?? 'bottom-start'; - } - - private _createMiddleware(): Middleware[] { - const shiftMiddleware = this.shift - ? shift({ padding: this.shiftPadding, limiter: limitShift() }) - : null; - const flipMiddleware = this.flip ? flip() : null; - - // Aligned placements flip before shifting, base placements shift first. - // See https://floating-ui.com/docs/flip - const positioners = this._placement.includes('-') - ? [flipMiddleware, shiftMiddleware] - : [shiftMiddleware, flipMiddleware]; - - const chain = [ - this.offset !== 0 ? offset(this.offset) : null, - this.inline ? inline() : null, - ...positioners, - this.sameWidth - ? size({ - apply: ({ rects }) => - setStyles(this._container, { - width: `${rects.reference.width}px`, - }), - }) - : null, - this.arrow ? arrow({ element: this.arrow }) : null, - ]; - - return chain.filter((entry): entry is Middleware => entry !== null); - } - - private async _updatePosition(): Promise { - if (!this.open) { - return; - } - - if (!this._target?.isConnected) { - this._target = undefined; - this._clearDispose(); - this._setPopoverState(false); - return; - } - - const positionId = ++this._positionId; - const strategy = this._strategy; - - const { x, y, middlewareData, placement } = await computePosition( - this._target, - this._container, - { - placement: this._placement, - middleware: (this._middleware ??= this._createMiddleware()), - strategy, - } - ); - - if (positionId !== this._positionId || !this.open) { - return; + private _showPopover(): void { + const container = this._container; + const strategy = this._positionStrategy; + + if (strategy?.native) { + container.showPopover({ source: this._target as HTMLElement }); + this._shownSource = this._target; + // The browser positions the container now. Update the arrow to match. + strategy.update(); + } else { + container.showPopover(); } - - setStyles(this._container, { - position: strategy, - left: '0', - top: '0', - transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`, - }); - - this._updateArrowPosition(placement, middlewareData); } - private _updateArrowPosition( - placement: Placement, - data: MiddlewareData - ): void { - const element = this.arrow; - - if (!(data.arrow && element)) { - return; - } - - const { x, y } = data.arrow; - const offset = this.arrowOffset; - const [side] = placement.split('-') as [PopoverSide]; - const staticSide = OPPOSITE_SIDE[side]; - - if (!element.part.contains(side)) { - element.part.remove(...SIDES); - element.part.add(side); - } - - // Measured after the part switch, since it is what gives the arrow its size. - const inset = - staticSide === 'top' || staticSide === 'bottom' - ? element.offsetHeight - : element.offsetWidth; - - // Every side is reset, otherwise the inset of the previous placement is left - // behind and over-constrains the arrow. - const styles: Partial = { - top: y != null ? `${roundByDPR(y + offset)}px` : '', - right: '', - bottom: '', - left: x != null ? `${roundByDPR(x + offset)}px` : '', - }; - - styles[staticSide] = `${-inset}px`; - - setStyles(element, styles); + private _hidePopover(): void { + this._shownSource = undefined; + this._container.hidePopover(); } //#endregion @@ -414,6 +365,8 @@ export default class IgcPopoverComponent extends LitElement { id="container" part="container" popover="manual" + data-placement=${resolvePlacement(this)} + data-scroll-strategy=${this.scrollStrategy} @toggle=${this._handleToggle} > diff --git a/src/components/popover/position/arrow.ts b/src/components/popover/position/arrow.ts new file mode 100644 index 000000000..be52e2a17 --- /dev/null +++ b/src/components/popover/position/arrow.ts @@ -0,0 +1,53 @@ +import { roundByDPR, setStyles } from '#internals/utils/dom.js'; + +export const OPPOSITE_SIDE = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', +} as const; + +export type PopoverSide = keyof typeof OPPOSITE_SIDE; + +const SIDES = Object.keys(OPPOSITE_SIDE) as PopoverSide[]; + +/** + * Sets the part and the inline styles of the arrow for the given `side`. + * + * Both position strategies call this function. Therefore the arrow gets the + * same styles for each strategy. + */ +export function applyArrowStyles( + element: HTMLElement, + side: PopoverSide, + x: number | undefined, + y: number | undefined, + offset: number +): void { + const staticSide = OPPOSITE_SIDE[side]; + + if (!element.part.contains(side)) { + element.part.remove(...SIDES); + element.part.add(side); + } + + // The part gives the arrow its size. Measure the size after the part + // changes. + const inset = + staticSide === 'top' || staticSide === 'bottom' + ? element.offsetHeight + : element.offsetWidth; + + // Reset every side. If a side keeps the inset of the previous placement, + // that inset over-constrains the arrow. + const styles: Partial = { + top: y != null ? `${roundByDPR(y + offset)}px` : '', + right: '', + bottom: '', + left: x != null ? `${roundByDPR(x + offset)}px` : '', + }; + + styles[staticSide] = `${-inset}px`; + + setStyles(element, styles); +} diff --git a/src/components/popover/position/floating.ts b/src/components/popover/position/floating.ts new file mode 100644 index 000000000..0a69eae24 --- /dev/null +++ b/src/components/popover/position/floating.ts @@ -0,0 +1,232 @@ +import type * as FloatingUi from '@floating-ui/dom'; +import type { Middleware } from '@floating-ui/dom'; +import { + hasStickyAncestor, + roundByDPR, + setStyles, +} from '#internals/utils/dom.js'; +import { applyArrowStyles, type PopoverSide } from './arrow.js'; +import { + type PopoverPositionHost, + type PopoverPositionStrategy, + type PopoverPositionStrategyCallbacks, + resolvePlacement, +} from './types.js'; + +type FloatingUiModule = typeof FloatingUi; + +/** + * The module loads on demand when the fallback strategy attaches for the + * first time. A browser that uses the native strategy never loads it. + * + * The published build is ESM and uses no bundler. Therefore the bundler of a + * consumer can split the code at this import. + */ +let floatingUiModule: FloatingUiModule | undefined; +let floatingUiLoader: Promise | undefined; + +function loadFloatingUi(): Promise { + floatingUiLoader ??= import('@floating-ui/dom').then((module) => { + floatingUiModule = module; + return module; + }); + + return floatingUiLoader; +} + +/** + * The position strategy that uses `@floating-ui/dom` and JavaScript. + * + * The popover uses this strategy if the browser does not support the CSS + * anchor positioning. + */ +export class FloatingPositionStrategy implements PopoverPositionStrategy { + public readonly native = false; + + private readonly _host: PopoverPositionHost; + private readonly _callbacks: PopoverPositionStrategyCallbacks; + + private _target?: Element; + private _container?: HTMLElement; + private _dispose?: () => void; + private _middleware?: Middleware[]; + private _positionId = 0; + + /** + * Each call to `detach` increments this counter. An `attach` call that + * waits for the first module load compares the counter. The `attach` call + * stops if a later call replaced it. + */ + private _attachId = 0; + + /** + * The value is `fixed` if an ancestor of the anchor has `position: sticky`. + * In all other cases the value is `absolute`. + * + * The strategy calculates the value one time for each open cycle, because + * the calculation walks the DOM and forces a style reflow. + */ + private _strategy: 'absolute' | 'fixed' = 'absolute'; + + constructor( + host: PopoverPositionHost, + callbacks: PopoverPositionStrategyCallbacks + ) { + this._host = host; + this._callbacks = callbacks; + } + + public attach(target: Element, container: HTMLElement): void { + this.detach(); + + this._target = target; + this._container = container; + this._middleware = undefined; + + // If the popover closes while the anchor is out of view, the container + // keeps `visibility: hidden`. The first `computePosition` call is + // asynchronous, so it clears that style too late. Reset the style here. + // The popover then never opens as invisible. + setStyles(container, { visibility: '' }); + + if (!this._host.sameWidth) { + // Remove the width that the `sameWidth` option set in a previous open + // cycle. + setStyles(container, { width: '' }); + } + + this._strategy = hasStickyAncestor(target) ? 'fixed' : 'absolute'; + + if (floatingUiModule) { + this._startAutoUpdate(floatingUiModule, target, container); + } else { + const attachId = this._attachId; + + loadFloatingUi().then((module) => { + if (attachId === this._attachId) { + this._startAutoUpdate(module, target, container); + } + }); + } + } + + public update(): void { + this._middleware = undefined; + + if (!this._host.sameWidth && this._container) { + setStyles(this._container, { width: '' }); + } + + this._updatePosition(); + } + + public detach(): void { + this._attachId++; + this._dispose?.(); + this._dispose = undefined; + } + + public clear(): void { + if (this._container) { + setStyles(this._container, { + position: '', + left: '', + top: '', + transform: '', + width: '', + visibility: '', + }); + } + } + + private _startAutoUpdate( + floating: FloatingUiModule, + target: Element, + container: HTMLElement + ): void { + this._dispose = floating.autoUpdate( + target, + container, + this._updatePosition.bind(this) + ); + } + + private _createMiddleware(floating: FloatingUiModule): Middleware[] { + const host = this._host; + + const chain = [ + host.offset !== 0 ? floating.offset(host.offset) : null, + host.flip ? floating.flip() : null, + host.sameWidth + ? floating.size({ + apply: ({ rects }) => { + if (this._container) { + setStyles(this._container, { + width: `${rects.reference.width}px`, + }); + } + }, + }) + : null, + host.arrow ? floating.arrow({ element: host.arrow }) : null, + // This middleware matches `position-visibility: anchors-visible` of the + // native strategy. It hides the container while the anchor is fully out + // of view. The `scroll` strategy adds no middleware, which matches + // `position-visibility: always`. + host.scrollStrategy !== 'scroll' ? floating.hide() : null, + ]; + + return chain.filter((entry): entry is Middleware => entry !== null); + } + + private async _updatePosition(): Promise { + const container = this._container; + // The module is available whenever `autoUpdate` runs. A pending `attach` + // positions the container when the module loads. Therefore an update that + // runs before the first load can stop here. + const floating = floatingUiModule; + + if (!(this._host.open && container && floating)) { + return; + } + + if (!this._target?.isConnected) { + this._callbacks.onAnchorRemoved(); + return; + } + + const positionId = ++this._positionId; + const strategy = this._strategy; + + const { x, y, middlewareData, placement } = await floating.computePosition( + this._target, + container, + { + placement: resolvePlacement(this._host), + middleware: (this._middleware ??= this._createMiddleware(floating)), + strategy, + } + ); + + if (positionId !== this._positionId || !this._host.open) { + return; + } + + setStyles(container, { + position: strategy, + left: '0', + top: '0', + transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`, + visibility: middlewareData.hide?.referenceHidden ? 'hidden' : '', + }); + + const { arrow, arrowOffset } = this._host; + + if (arrow && middlewareData.arrow) { + const [side] = placement.split('-') as [PopoverSide]; + const { x: arrowX, y: arrowY } = middlewareData.arrow; + + applyArrowStyles(arrow, side, arrowX, arrowY, arrowOffset); + } + } +} diff --git a/src/components/popover/position/native.ts b/src/components/popover/position/native.ts new file mode 100644 index 000000000..77bd89a24 --- /dev/null +++ b/src/components/popover/position/native.ts @@ -0,0 +1,293 @@ +import { getRoot, isPopoverOpen, setStyles } from '#internals/utils/dom.js'; +import { clamp } from '#internals/utils/math.js'; +import { applyArrowStyles, type PopoverSide } from './arrow.js'; +import { + getForcedPopoverPositionStrategy, + type PopoverPositionHost, + type PopoverPositionStrategy, + type PopoverPositionStrategyCallbacks, + resolvePlacement, + SUPPORTS_ANCHOR_POSITIONING, +} from './types.js'; + +const OFFSET_PROPERTY = '--_igc-popover-offset'; + +let implicitAnchorUsable: boolean | undefined; + +/** + * Tests the implicit anchor of `showPopover({ source })` one time. + * + * `CSS.supports` cannot detect this feature. Chromium 125 to 132 passes the + * CSS tests, but it ignores the `source` option. The browser then shows the + * popover at the centered default position. + */ +function canUseImplicitAnchor(): boolean { + if (implicitAnchorUsable !== undefined) { + return implicitAnchorUsable; + } + + const anchor = document.createElement('div'); + const popover = document.createElement('div'); + + popover.popover = 'manual'; + + setStyles(anchor, { + position: 'fixed', + top: '0', + left: '0', + width: '1px', + height: '1px', + }); + setStyles(popover, { + margin: '0', + inset: 'auto', + border: 'none', + padding: '0', + width: '1px', + height: '1px', + }); + popover.style.setProperty('position-area', 'bottom'); + + try { + document.body.append(anchor, popover); + popover.showPopover({ source: anchor }); + + // If the browser anchors the popover, the popover sits directly below + // the anchor of 1 pixel. The tolerance allows for fractional rounding. + // If the browser ignores `source`, the popover sits at the centered + // default position, which is far away. + implicitAnchorUsable = + Math.abs(popover.getBoundingClientRect().top - 1) <= 1; + + popover.hidePopover(); + } catch { + implicitAnchorUsable = false; + } finally { + anchor.remove(); + popover.remove(); + } + + return implicitAnchorUsable; +} + +/** + * True if the popover can position the `target` with the native CSS anchor + * positioning. + * + * The `source` option accepts an HTMLElement. Therefore the floating-ui + * fallback positions an anchor that is not an HTML element, for example an + * SVG element. + */ +export function shouldUseNativeAnchorPositioning( + target: Element +): target is HTMLElement { + const forced = getForcedPopoverPositionStrategy(); + + if (forced) { + return forced === 'native'; + } + + return ( + SUPPORTS_ANCHOR_POSITIONING && + target instanceof HTMLElement && + canUseImplicitAnchor() + ); +} + +/** + * The position strategy that uses the native CSS anchor positioning. + * + * The host shows the container with `showPopover({ source: target })`. This + * call establishes the implicit anchor. The implicit anchor is necessary, + * because `anchor-name` is tree-scoped and cannot cross the shadow boundary. + * + * The CSS rules then do all the positioning. The rules apply only when the + * `data-anchored` attribute is present. This strategy owns that attribute. + */ +export class NativePositionStrategy implements PopoverPositionStrategy { + public readonly native = true; + + private readonly _host: PopoverPositionHost; + private readonly _callbacks: PopoverPositionStrategyCallbacks; + + private _target?: Element; + private _container?: HTMLElement; + private _observer?: MutationObserver; + private _arrowFrame = 0; + + constructor( + host: PopoverPositionHost, + callbacks: PopoverPositionStrategyCallbacks + ) { + this._host = host; + this._callbacks = callbacks; + } + + public attach(target: Element, container: HTMLElement): void { + this.detach(); + + this._target = target; + this._container = container; + + container.toggleAttribute('data-anchored', true); + this._syncOffset(); + this._observeAnchorRemoval(target); + this._syncArrowWatcher(); + } + + public update(): void { + this._syncOffset(); + this._syncArrowWatcher(); + this._updateArrow(); + // Once more after layout settles - the container content may still be + // sizing right after showPopover. + this._scheduleArrowUpdate(); + } + + public detach(): void { + this._observer?.disconnect(); + this._observer = undefined; + this._removeArrowListeners(); + } + + public clear(): void { + if (this._container) { + this._container.toggleAttribute('data-anchored', false); + this._container.style.removeProperty(OFFSET_PROPERTY); + } + } + + private _syncOffset(): void { + this._container?.style.setProperty( + OFFSET_PROPERTY, + `${this._host.offset}px` + ); + } + + private _observeAnchorRemoval(target: Element): void { + // If the anchor leaves the DOM and returns in the same task, this + // observer does nothing. The implicit anchor holds an element reference, + // so the browser anchors the container again. + // This observer does not detect the removal of a shadow host above the + // root of the anchor. The fallback strategy does not detect it either. + this._observer = new MutationObserver(() => { + if (!target.isConnected) { + this._callbacks.onAnchorRemoved(); + } + }); + + this._observer.observe(getRoot(target), { + childList: true, + subtree: true, + }); + } + + //#region Arrow support + + /** + * The CSS rules position the container, but the arrow needs JavaScript. + * CSS gives no signal about the position-try fallback that the browser + * applies. Also, a descendant of the container cannot reference the + * implicit anchor. + * + * The strategy calculates the side from the rectangles of the container + * and the anchor. It repeats the calculation on scroll and on resize while + * the popover has an arrow. + */ + private readonly _handleArrowInvalidation = (): void => { + this._scheduleArrowUpdate(); + }; + + /** + * The listener reference is stable. Therefore `addEventListener` and + * `removeEventListener` are idempotent, and this method needs no state. + */ + private _syncArrowWatcher(): void { + this._host.arrow ? this._addArrowListeners() : this._removeArrowListeners(); + } + + private _addArrowListeners(): void { + window.addEventListener('scroll', this._handleArrowInvalidation, { + capture: true, + passive: true, + }); + window.addEventListener('resize', this._handleArrowInvalidation); + } + + private _removeArrowListeners(): void { + window.removeEventListener('scroll', this._handleArrowInvalidation, { + capture: true, + }); + window.removeEventListener('resize', this._handleArrowInvalidation); + + cancelAnimationFrame(this._arrowFrame); + this._arrowFrame = 0; + } + + private _scheduleArrowUpdate(): void { + if (this._arrowFrame || !this._host.arrow) { + return; + } + + this._arrowFrame = requestAnimationFrame(() => { + this._arrowFrame = 0; + this._updateArrow(); + }); + } + + private _updateArrow(): void { + const { arrow, arrowOffset } = this._host; + const target = this._target; + const container = this._container; + + if (!(arrow && target && container && isPopoverOpen(container))) { + return; + } + + const anchorRect = target.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const [base] = resolvePlacement(this._host).split('-') as [PopoverSide]; + + // Compare the centers to find the side that the container uses after a + // flip fallback. This test stays correct for an offset gap and for a + // negative offset that overlaps the anchor. + if (base === 'top' || base === 'bottom') { + const side = + containerRect.top + containerRect.height / 2 < + anchorRect.top + anchorRect.height / 2 + ? 'top' + : 'bottom'; + + // Center the arrow on the anchor. Keep the arrow inside the container. + const x = clamp( + anchorRect.left + + anchorRect.width / 2 - + containerRect.left - + arrow.offsetWidth / 2, + 0, + container.clientWidth - arrow.offsetWidth + ); + + applyArrowStyles(arrow, side, x, undefined, arrowOffset); + } else { + const side = + containerRect.left + containerRect.width / 2 < + anchorRect.left + anchorRect.width / 2 + ? 'left' + : 'right'; + + const y = clamp( + anchorRect.top + + anchorRect.height / 2 - + containerRect.top - + arrow.offsetHeight / 2, + 0, + container.clientHeight - arrow.offsetHeight + ); + + applyArrowStyles(arrow, side, undefined, y, arrowOffset); + } + } + + //#endregion +} diff --git a/src/components/popover/position/types.ts b/src/components/popover/position/types.ts new file mode 100644 index 000000000..bade9a71d --- /dev/null +++ b/src/components/popover/position/types.ts @@ -0,0 +1,93 @@ +import { isServer } from 'lit'; +import type { PopoverScrollStrategy } from '../../types.js'; +import type { PopoverPlacement } from '../popover.js'; + +/** + * True if the browser supports the CSS features that the native strategy + * needs. + * + * `CSS.supports` cannot detect the implicit anchor of + * `showPopover({ source })`. The `native.ts` module tests that feature + * separately. + */ +export const SUPPORTS_ANCHOR_POSITIONING = + !isServer && + typeof CSS !== 'undefined' && + CSS.supports('anchor-name: --a') && + // Some engines support only a part of the `position-area` grammar. The + // aligned placements need the span keywords, so test one of them. + CSS.supports('position-area: top span-right'); + +type PopoverPositionStrategyMode = 'native' | 'floating'; + +let forcedStrategy: PopoverPositionStrategyMode | undefined; + +/** @internal Forces one position strategy. Only the tests call this function. */ +export function setPopoverPositionStrategy( + mode?: PopoverPositionStrategyMode +): void { + forcedStrategy = mode; +} + +/** @internal Returns the forced position strategy, if the tests set one. */ +export function getForcedPopoverPositionStrategy(): + | PopoverPositionStrategyMode + | undefined { + return forcedStrategy; +} + +/** + * The read-only inputs that a position strategy reads from the popover + * component. + */ +export interface PopoverPositionHost { + /** The value is null or undefined at run time if the attribute is removed. */ + readonly placement: PopoverPlacement | null | undefined; + readonly offset: number; + readonly flip: boolean; + readonly sameWidth: boolean; + readonly arrow: HTMLElement | null; + readonly arrowOffset: number; + readonly open: boolean; + readonly scrollStrategy: PopoverScrollStrategy; +} + +/** + * Returns the placement of the host. Returns the default placement if the + * attribute is removed. + */ +export function resolvePlacement(host: PopoverPositionHost): PopoverPlacement { + return host.placement ?? 'bottom-start'; +} + +export interface PopoverPositionStrategyCallbacks { + /** + * The strategy calls this callback if the anchor leaves the DOM while the + * popover is open. The host then hides the container. The host does not + * change the `open` property. + */ + onAnchorRemoved(): void; +} + +export interface PopoverPositionStrategy { + /** + * True if the strategy uses the native CSS anchor positioning. If it is + * true, the host shows the container with `showPopover({ source })`. + */ + readonly native: boolean; + + /** Starts to position the `container` against the `target`. */ + attach(target: Element, container: HTMLElement): void; + + /** Positions the container again after a host property changes. */ + update(): void; + + /** Stops all observers, listeners and pending updates of the strategy. */ + detach(): void; + + /** + * Removes the styles and the attributes that the strategy owns from the + * container. The host calls this method when it changes the strategy. + */ + clear(): void; +} diff --git a/src/components/popover/themes/light/popover.base.scss b/src/components/popover/themes/light/popover.base.scss index 6bc27edca..b264fb56d 100644 --- a/src/components/popover/themes/light/popover.base.scss +++ b/src/components/popover/themes/light/popover.base.scss @@ -9,8 +9,185 @@ overflow: visible; isolation: isolate; height: fit-content; + + // `unset` computes to `auto`. The inline styles of the fallback need that + // value. The `position-area` rules below also need it. inset: unset; border: none; padding: 0; background: transparent; } + +// ----------------------------------------------------------------------------- +// Native CSS anchor positioning. +// ----------------------------------------------------------------------------- +// These rules apply only when the [data-anchored] attribute is present. The +// native strategy sets that attribute from JavaScript. The rules do not use +// @supports for this test. A test can force a browser with full support onto +// the floating-ui fallback. That browser must not match these rules, because +// the browser must then use the inline left, top and transform styles of the +// fallback. +// ----------------------------------------------------------------------------- +// The anchor is the implicit anchor of showPopover({ source }). The rules +// cannot use anchor-name, because anchor-name is tree-scoped and cannot cross +// the shadow boundary. +// ----------------------------------------------------------------------------- +// All rules use the physical keywords. The container is in the top layer, +// where the logical keywords resolve against the writing mode of the root +// element. Current engines also do not implement the *-self-* keywords. +// ----------------------------------------------------------------------------- +// floating-ui resolves -start and -end from the direction of the floating +// element. The :dir() guards select the physical side that matches this +// behavior. :dir() follows the dir attribute only. Therefore a `direction: +// rtl` declaration in CSS does not flip these placements. +// ----------------------------------------------------------------------------- +// Only the top and bottom placements with -start and -end react to the +// direction. The left and right placements with -start and -end always use +// the physical top and bottom. floating-ui behaves the same way. +// ----------------------------------------------------------------------------- +// Each rule sets the alignment explicitly. No rule depends on the `normal` +// alignment of a spanning area. +// ----------------------------------------------------------------------------- +[data-anchored] { + // Override the `margin: auto` that the user agent sets on [popover]. An + // automatic margin breaks anchor-center and the alignment to an edge. + margin: 0; + + // Hide the popover while the anchor is fully out of view. This applies to + // the `hide` and the `close` scroll strategy. `hide` is the default + // strategy. The fallback strategy uses the hide() middleware to get the + // same result. Safari 26.0 and 26.1 support the anchor positioning, but + // they do not support position-visibility. These browsers ignore the + // declaration and keep the popover visible. This result is acceptable. + position-visibility: anchors-visible; + + &[data-placement='top'] { + position-area: top; + justify-self: anchor-center; + } + + &[data-placement='bottom'] { + position-area: bottom; + justify-self: anchor-center; + } + + &:dir(ltr) { + &[data-placement='top-start'] { + position-area: top span-right; + justify-self: left; + } + + &[data-placement='top-end'] { + position-area: top span-left; + justify-self: right; + } + + &[data-placement='bottom-start'] { + position-area: bottom span-right; + justify-self: left; + } + + &[data-placement='bottom-end'] { + position-area: bottom span-left; + justify-self: right; + } + } + + &:dir(rtl) { + &[data-placement='top-start'] { + position-area: top span-left; + justify-self: right; + } + + &[data-placement='top-end'] { + position-area: top span-right; + justify-self: left; + } + + &[data-placement='bottom-start'] { + position-area: bottom span-left; + justify-self: right; + } + + &[data-placement='bottom-end'] { + position-area: bottom span-right; + justify-self: left; + } + } + + &[data-placement='left'] { + position-area: left; + align-self: anchor-center; + } + + &[data-placement='left-start'] { + position-area: left span-bottom; + align-self: self-start; + } + + &[data-placement='left-end'] { + position-area: left span-top; + align-self: self-end; + } + + &[data-placement='right'] { + position-area: right; + align-self: anchor-center; + } + + &[data-placement='right-start'] { + position-area: right span-bottom; + align-self: self-start; + } + + &[data-placement='right-end'] { + position-area: right span-top; + align-self: self-end; + } + + // The offset uses symmetric margins on the main axis of the placement. + // The margin on the anchor side makes the gap. The margin on the far side + // has no effect on the position. The rule therefore stays correct if a + // flip tactic transforms the margin properties. The rules set no margin + // on the cross axis, because such a margin breaks the alignment of the + // -start and -end placements. A negative offset overlaps the anchor. + // floating-ui behaves the same way. + &[data-placement^='top'], + &[data-placement^='bottom'] { + margin-top: var(--_igc-popover-offset, 0); + margin-bottom: var(--_igc-popover-offset, 0); + } + + &[data-placement^='left'], + &[data-placement^='right'] { + margin-left: var(--_igc-popover-offset, 0); + margin-right: var(--_igc-popover-offset, 0); + } +} + +// The `scroll` strategy does not hide the popover. The popover follows the +// anchor while the anchor is fully out of view. +[data-anchored][data-scroll-strategy='scroll'] { + position-visibility: always; +} + +// The popover flips only to the opposite side on the main axis. The default +// flip() middleware of the fallback behaves the same way. Neither one flips +// across the axes. +:host([flip]) [data-anchored] { + &[data-placement^='top'], + &[data-placement^='bottom'] { + position-try-fallbacks: flip-block; + } + + &[data-placement^='left'], + &[data-placement^='right'] { + position-try-fallbacks: flip-inline; + } +} + +// anchor-size() resolves against the implicit anchor. The native strategy +// sets no inline width. Therefore no inline width overrides this rule. +:host([same-width]) [data-anchored] { + width: anchor-size(width); +} diff --git a/src/components/select/select.spec.ts b/src/components/select/select.spec.ts index e11619d8a..713624f54 100644 --- a/src/components/select/select.spec.ts +++ b/src/components/select/select.spec.ts @@ -333,6 +333,7 @@ describe('Select', () => { }); it('`scroll` behavior', async () => { + select.scrollStrategy = 'scroll'; await openSelect(); await simulateScroll(container, { top: 200 }); @@ -350,14 +351,6 @@ describe('Select', () => { expect(eventSpy.firstCall).calledWith('igcClosing'); expect(eventSpy.lastCall).calledWith('igcClosed'); }); - - it('`block behavior`', async () => { - select.scrollStrategy = 'block'; - await openSelect(); - await simulateScroll(container, { top: 200 }); - - expect(select.open).to.be.true; - }); }); describe('Initial selection', () => { diff --git a/src/components/select/select.ts b/src/components/select/select.ts index 6d4799e44..ff98d6c78 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -21,7 +21,6 @@ import { type MutationControllerParams, } from '#internals/controllers/mutation-observer.js'; import { addRootClickController } from '#internals/controllers/root-click.js'; -import { addRootScrollHandler } from '#internals/controllers/root-scroll.js'; import { addSlotController, setSlots } from '#internals/controllers/slot.js'; import { blazorAdditionalDependencies } from '#internals/decorators/blazorAdditionalDependencies.js'; import { shadowOptions } from '#internals/decorators/shadow-options.js'; @@ -159,10 +158,6 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( private readonly _slots = addSlotController(this, { slots: Slots }); - private readonly _rootScrollController = addRootScrollHandler(this, { - hideCallback: this._handleClosing, - }); - protected override readonly _rootClickController = addRootClickController( this, { @@ -266,11 +261,18 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( public placement: PopoverPlacement = 'bottom-start'; /** - * Determines the behavior of the component during scrolling of the parent container. + * Sets the behavior of the component when the parent container scrolls. + * + * If the value is `hide`, the component hides while the anchor is fully out + * of view. `hide` is the default value. + * + * If the value is `scroll`, the component stays visible and anchored. + * + * If the value is `close`, the component closes on each scroll. * @attr scroll-strategy */ @property({ attribute: 'scroll-strategy' }) - public scrollStrategy: PopoverScrollStrategy = 'scroll'; + public scrollStrategy: PopoverScrollStrategy = 'hide'; /** Returns the items of the select component. */ public get items(): IgcSelectItemComponent[] { @@ -300,13 +302,8 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( return; } - if (changedProperties.has('scrollStrategy')) { - this._rootScrollController.update({ resetListeners: true }); - } - if (changedProperties.has('open')) { this._rootClickController.update(); - this._rootScrollController.update(); } } @@ -820,10 +817,11 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin( ${this._renderInputAnchor()} ${this._renderDropdown()} diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index ab224a45f..90cc732ec 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -15,6 +15,7 @@ import { simulateFocus, simulatePointerEnter, simulatePointerLeave, + simulateScroll, } from '#internals/testing/simulate.spec.js'; import IgcTooltipComponent from './tooltip.js'; @@ -97,7 +98,6 @@ describe('Tooltip', () => { `
@@ -115,7 +115,6 @@ describe('Tooltip', () => { expect(tooltip).shadowDom.to.equal( `
@@ -426,7 +425,6 @@ describe('Tooltip', () => { `
@@ -691,6 +689,52 @@ describe('Tooltip', () => { }); }); + describe('Scroll strategy', () => { + let container: HTMLElement; + + beforeEach(async () => { + container = await fixture(html` +
+ + It works! +
+ `); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + it('`scroll` behavior', async () => { + tooltip.scrollStrategy = 'scroll'; + await tooltip.show(); + await simulateScroll(container, { top: 200 }); + await hideComplete(); + + expect(tooltip.open).to.be.true; + }); + + it('`close` behavior', async () => { + const eventSpy = spy(tooltip, 'emitEvent'); + + tooltip.scrollStrategy = 'close'; + await tooltip.show(); + await simulateScroll(container, { top: 200 }); + await hideComplete(); + + expect(tooltip.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); + }); + + it('`close` dismisses a sticky tooltip too', async () => { + tooltip.sticky = true; + tooltip.scrollStrategy = 'close'; + await tooltip.show(); + await simulateScroll(container, { top: 200 }); + await hideComplete(); + + expect(tooltip.open).to.be.false; + }); + }); + describe('Behaviors', () => { beforeEach(async () => { clock = useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index ebc2fc708..f98070c6f 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -22,6 +22,7 @@ import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent, { type PopoverPlacement, } from '../popover/popover.js'; +import type { PopoverScrollStrategy } from '../types.js'; import { addTooltipController } from './controller.js'; import { styles as shared } from './themes/shared/tooltip.common.css.js'; import { all } from './themes/themes.js'; @@ -206,6 +207,22 @@ export default class IgcTooltipComponent extends EventEmitterMixin< @property() public placement: PopoverPlacement = 'bottom'; + /** + * Sets the behavior of the tooltip when the parent container scrolls. + * + * If the value is `hide`, the tooltip hides while the anchor is fully out + * of view. `hide` is the default value. + * + * If the value is `scroll`, the tooltip stays visible and anchored. + * + * If the value is `close`, the tooltip closes on each scroll. The tooltip + * also closes if you set the `sticky` property. The Escape key behaves the + * same way. + * @attr scroll-strategy + */ + @property({ attribute: 'scroll-strategy' }) + public scrollStrategy: PopoverScrollStrategy = 'hide'; + /** * An element instance or an IDREF to use as the anchor for the tooltip. * @@ -511,8 +528,16 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } } - /** Sticky mode close action - closes without waiting out `hideDelay`. */ - private _hideOnCloseClick(): void { + /** + * Closes the tooltip and emits the events. The method ignores `hideDelay`. + * The method also ignores the `sticky` property, unlike + * `_hideOnInteraction`. + * + * The close button of a sticky tooltip calls this method. The `close` + * scroll strategy also calls it, because that strategy closes a sticky + * tooltip too. + */ + private _hideImmediately(): void { this._applyTooltipState({ show: false, withEvents: true }); } @@ -536,17 +561,17 @@ export default class IgcTooltipComponent extends EventEmitterMixin< .offset=${this.offset} .anchor=${this._controller.anchor ?? undefined} .arrowOffset=${this._arrowOffset} - .shiftPadding=${8} + .scrollStrategy=${this.scrollStrategy} ?open=${this.open} flip - shift + @igcPopoverScrollClose=${this._hideImmediately} >
${this.message} ${ this.sticky ? html` - +