From e729ef84a8dd2d362a3bfee9ac0b17e129941c95 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 22 Apr 2026 20:35:47 +0300 Subject: [PATCH 1/2] feat: Add container positioning to Toast and Snackbar components - Both toast and snackbar are now popovers, ensuring top-layer rendering and no need for z-index management. Closes #2154 --- src/components/common/mixins/alert.ts | 94 ++++++++++++++- src/components/snackbar/snackbar.ts | 18 +-- src/components/toast/toast.ts | 3 - stories/snackbar.stories.ts | 135 +++++++++++++++++++++- stories/toast.stories.ts | 159 +++++++++++++++++++++++--- 5 files changed, 371 insertions(+), 38 deletions(-) diff --git a/src/components/common/mixins/alert.ts b/src/components/common/mixins/alert.ts index 3796bc7178..698558b04d 100644 --- a/src/components/common/mixins/alert.ts +++ b/src/components/common/mixins/alert.ts @@ -1,18 +1,50 @@ import { LitElement, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; -import type { AnimationController } from '../../../animations/player.js'; +import { addAnimationController } from '../../../animations/player.js'; import { fadeIn, fadeOut } from '../../../animations/presets/fade/index.js'; import type { AbsolutePosition } from '../../types.js'; import { addInternalsController } from '../controllers/internals.js'; -// It'd be better to have this as a mixin rather than a base class once the analyzer -// knows how to resolve multiple mixin chains +function getVisibleAncestor(startNode: Node): HTMLElement | null { + let node: Node | null = startNode.parentNode; + + while (node) { + if (node instanceof ShadowRoot) { + node = node.host; + continue; + } + + if (node instanceof HTMLElement && node.checkVisibility()) { + return node; + } + + node = node.parentNode; + } + + return null; +} export abstract class IgcBaseAlertLikeComponent extends LitElement { - declare protected abstract readonly _player: AnimationController; + protected readonly _player = addAnimationController(this); protected _autoHideTimeout?: ReturnType; + private get _isContained(): boolean { + return this.positioning === 'container'; + } + + // TODO: Move this to styles, i.e. :host([position="top"]) { top: anchor(top); } etc. + private get _containerPosition(): string { + switch (this.position) { + case 'top': + return 'anchor(top)'; + case 'bottom': + return 'calc(anchor(bottom) - 25%)'; + default: + return 'anchor(center)'; + } + } + /** * Whether the component is in shown state. * @attr @@ -41,6 +73,9 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { @property({ reflect: true }) public position: AbsolutePosition = 'bottom'; + @property({ reflect: true }) + public positioning: 'viewport' | 'container' = 'viewport'; + constructor() { super(); @@ -52,13 +87,53 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { }); } - protected override updated(props: PropertyValues): void { + public override connectedCallback(): void { + super.connectedCallback(); + this.popover = 'manual'; + } + + protected override update(props: PropertyValues): void { if (props.has('displayTime')) { this._setAutoHideTimer(); } if (props.has('keepOpen')) { - clearTimeout(this._autoHideTimeout); + this.keepOpen + ? clearTimeout(this._autoHideTimeout) + : this._setAutoHideTimer(); + } + + if (this.open && (props.has('positioning') || props.has('position'))) { + this._hidePopover(); + this._showPopover(); + } + + super.update(props); + } + + private _showPopover(): boolean { + if (!this._isContained) { + this.showPopover(); + return true; + } + + const visibleAncestor = getVisibleAncestor(this); + if (!visibleAncestor) { + return false; + } + + this.style.top = this._containerPosition; + this.style.left = 'anchor(center)'; + this.showPopover({ source: visibleAncestor }); + return true; + } + + private _hidePopover(): void { + this.hidePopover(); + + if (this._isContained) { + this.style.removeProperty('top'); + this.style.removeProperty('left'); } } @@ -67,11 +142,18 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { if (open) { this.open = open; + + if (!this._showPopover()) { + this.open = false; + return false; + } + state = await this._player.playExclusive(fadeIn()); this._setAutoHideTimer(); } else { clearTimeout(this._autoHideTimeout); state = await this._player.playExclusive(fadeOut()); + this._hidePopover(); this.open = open; } diff --git a/src/components/snackbar/snackbar.ts b/src/components/snackbar/snackbar.ts index f6d66e41b2..9eafb5fa5f 100644 --- a/src/components/snackbar/snackbar.ts +++ b/src/components/snackbar/snackbar.ts @@ -1,7 +1,5 @@ import { html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; -import { createRef, ref } from 'lit/directives/ref.js'; -import { addAnimationController } from '../../animations/player.js'; import { addThemingController } from '../../theming/theming-controller.js'; import IgcButtonComponent from '../button/button.js'; import { registerComponent } from '../common/definitions/register.js'; @@ -44,12 +42,6 @@ export default class IgcSnackbarComponent extends EventEmitterMixin< registerComponent(IgcSnackbarComponent, IgcButtonComponent); } - protected readonly _contentRef = createRef(); - protected override readonly _player = addAnimationController( - this, - this._contentRef - ); - /** * The snackbar action button. * @attr action-text @@ -68,16 +60,18 @@ export default class IgcSnackbarComponent extends EventEmitterMixin< protected override render() { return html` -
+
${this.actionText - ? html` - ${this.actionText} - ` + ? html` + + ${this.actionText} + + ` : nothing}
diff --git a/src/components/toast/toast.ts b/src/components/toast/toast.ts index 6d04700fa3..1776418b06 100644 --- a/src/components/toast/toast.ts +++ b/src/components/toast/toast.ts @@ -1,5 +1,4 @@ import { html } from 'lit'; -import { addAnimationController } from '../../animations/player.js'; import { addThemingController } from '../../theming/theming-controller.js'; import { registerComponent } from '../common/definitions/register.js'; import { IgcBaseAlertLikeComponent } from '../common/mixins/alert.js'; @@ -18,8 +17,6 @@ export default class IgcToastComponent extends IgcBaseAlertLikeComponent { public static readonly tagName = 'igc-toast'; public static override styles = [styles, shared]; - protected override readonly _player = addAnimationController(this); - /* blazorSuppress */ public static register(): void { registerComponent(IgcToastComponent); diff --git a/stories/snackbar.stories.ts b/stories/snackbar.stories.ts index a8fb69559c..f3daf2f268 100644 --- a/stories/snackbar.stories.ts +++ b/stories/snackbar.stories.ts @@ -60,8 +60,20 @@ const metadata: Meta = { control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'bottom' } }, }, + positioning: { + type: '"viewport" | "container"', + options: ['viewport', 'container'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'viewport' } }, + }, + }, + args: { + open: false, + displayTime: 4000, + keepOpen: false, + position: 'bottom', + positioning: 'viewport', }, - args: { open: false, displayTime: 4000, keepOpen: false, position: 'bottom' }, }; export default metadata; @@ -77,6 +89,7 @@ interface IgcSnackbarArgs { keepOpen: boolean; /** Sets the position of the component in the viewport. */ position: 'bottom' | 'middle' | 'top'; + positioning: 'viewport' | 'container'; } type Story = StoryObj; @@ -200,3 +213,123 @@ export const Positions: Story = { `, }; + +export const ContainerPositioning: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'When `positioning` is set to `"container"`, the snackbar is anchored to its nearest visible ancestor instead of the viewport. Toggle each position independently to see how the snackbar is constrained within the boundary.', + }, + }, + }, + render: () => html` + + +
+

Container boundary

+ +
+ + ( + document.getElementById('cs-snackbar-top') as IgcSnackbarComponent + ).toggle()} + >Toggle Top + + ( + document.getElementById( + 'cs-snackbar-middle' + ) as IgcSnackbarComponent + ).toggle()} + >Toggle Middle + + ( + document.getElementById( + 'cs-snackbar-bottom' + ) as IgcSnackbarComponent + ).toggle()} + >Toggle Bottom +
+ +

+ Snackbars are anchored within this container +

+ + + target.hide()} + > + Top — container-positioned + + + target.hide()} + > + Middle — container-positioned + + + target.hide()} + > + Bottom — container-positioned + +
+ `, +}; diff --git a/stories/toast.stories.ts b/stories/toast.stories.ts index d48a1431b3..691d4a8647 100644 --- a/stories/toast.stories.ts +++ b/stories/toast.stories.ts @@ -49,8 +49,20 @@ const metadata: Meta = { control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'bottom' } }, }, + positioning: { + type: '"viewport" | "container"', + options: ['viewport', 'container'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'viewport' } }, + }, + }, + args: { + open: false, + displayTime: 4000, + keepOpen: false, + position: 'bottom', + positioning: 'viewport', }, - args: { open: false, displayTime: 4000, keepOpen: false, position: 'bottom' }, }; export default metadata; @@ -64,12 +76,16 @@ interface IgcToastArgs { keepOpen: boolean; /** Sets the position of the component in the viewport. */ position: 'bottom' | 'middle' | 'top'; + positioning: 'viewport' | 'container'; } type Story = StoryObj; // endregion export const Basic: Story = { + args: { + position: 'top', + }, parameters: { docs: { description: { @@ -89,21 +105,25 @@ export const Basic: Story = { Notification displayed - - (document.getElementById('toast-basic') as IgcToastComponent).show()} - >Show Toast - - (document.getElementById('toast-basic') as IgcToastComponent).hide()} - >Hide Toast - - (document.getElementById('toast-basic') as IgcToastComponent).toggle()} - >Toggle Toast +
+ + (document.getElementById('toast-basic') as IgcToastComponent).show()} + >Show + + (document.getElementById('toast-basic') as IgcToastComponent).hide()} + >Hide + + ( + document.getElementById('toast-basic') as IgcToastComponent + ).toggle()} + >Toggle +
`, }; @@ -179,3 +199,110 @@ export const KeepOpen: Story = {
`, }; + +export const ContainerPositioning: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'When `positioning` is set to `"container"`, the toast is anchored to its nearest visible ancestor instead of the viewport. Toggle each position independently to see how the toast is constrained within the boundary.', + }, + }, + }, + render: () => html` + + +
+

Container boundary

+ +
+ + ( + document.getElementById('ct-toast-top') as IgcToastComponent + ).toggle()} + >Toggle Top + + ( + document.getElementById('ct-toast-middle') as IgcToastComponent + ).toggle()} + >Toggle Middle + + ( + document.getElementById('ct-toast-bottom') as IgcToastComponent + ).toggle()} + >Toggle Bottom +
+ +

+ Toasts are anchored within this container +

+ + + Top — container-positioned + + + Middle — container-positioned + + + Bottom — container-positioned + +
+ `, +}; From a901e3ce0b4871d0c9173703c29052af74af15b0 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 14 May 2026 14:45:45 +0300 Subject: [PATCH 2/2] refactor: Alert mixin improvements and documentation --- src/components/common/mixins/alert.ts | 99 ++++++++++++++++-------- src/components/snackbar/snackbar.spec.ts | 79 +++++++++++++++++++ src/components/snackbar/snackbar.ts | 2 +- src/components/toast/toast.spec.ts | 63 +++++++++++++++ src/components/toast/toast.ts | 2 + 5 files changed, 211 insertions(+), 34 deletions(-) diff --git a/src/components/common/mixins/alert.ts b/src/components/common/mixins/alert.ts index 698558b04d..a03fc72c61 100644 --- a/src/components/common/mixins/alert.ts +++ b/src/components/common/mixins/alert.ts @@ -29,10 +29,6 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { protected _autoHideTimeout?: ReturnType; - private get _isContained(): boolean { - return this.positioning === 'container'; - } - // TODO: Move this to styles, i.e. :host([position="top"]) { top: anchor(top); } etc. private get _containerPosition(): string { switch (this.position) { @@ -47,32 +43,53 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { /** * Whether the component is in shown state. - * @attr + * + * @attr open + * @default false */ @property({ type: Boolean, reflect: true }) public open = false; /** - * Determines the duration in ms in which the component will be visible. + * Determines the duration in milliseconds in which the component will be visible. + * * @attr display-time + * @default 4000 */ @property({ type: Number, attribute: 'display-time' }) public displayTime = 4000; /** * Determines whether the component should close after the `displayTime` is over. + * * @attr keep-open + * @default false */ @property({ type: Boolean, reflect: true, attribute: 'keep-open' }) public keepOpen = false; /** * Sets the position of the component in the viewport. - * @attr + * + * `bottom` - positions the component at the bottom. This is the default. + * `middle` - positions the component at the center. + * `top` - positions the component at the top. + * + * @attr position + * @default 'bottom' */ @property({ reflect: true }) public position: AbsolutePosition = 'bottom'; + /** + * Sets the positioning strategy of the component. + * + * `viewport` - positions the component relative to the viewport, ignoring any ancestor elements. This is the default behavior. + * `container` - positions the component relative to the nearest visible ancestor. In this mode, the component will be constrained within the bounding box of the ancestor and will be positioned according to the `position` attribute. + * + * @attr positioning + * @default 'viewport' + */ @property({ reflect: true }) public positioning: 'viewport' | 'container' = 'viewport'; @@ -93,14 +110,12 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { } protected override update(props: PropertyValues): void { - if (props.has('displayTime')) { - this._setAutoHideTimer(); - } - - if (props.has('keepOpen')) { - this.keepOpen - ? clearTimeout(this._autoHideTimeout) - : this._setAutoHideTimer(); + if (props.has('open')) { + if (this.open && !this.matches(':popover-open')) { + this._showPopover(); + } else if (!this.open && this.matches(':popover-open')) { + this._hidePopover(); + } } if (this.open && (props.has('positioning') || props.has('position'))) { @@ -108,11 +123,19 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { this._showPopover(); } + if ( + props.has('open') || + props.has('displayTime') || + props.has('keepOpen') + ) { + this._setAutoHideTimer(); + } + super.update(props); } private _showPopover(): boolean { - if (!this._isContained) { + if (this.positioning !== 'container') { this.showPopover(); return true; } @@ -130,33 +153,28 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { private _hidePopover(): void { this.hidePopover(); - - if (this._isContained) { - this.style.removeProperty('top'); - this.style.removeProperty('left'); - } + this.style.removeProperty('top'); + this.style.removeProperty('left'); } private async _setOpenState(open: boolean): Promise { - let state: boolean; - if (open) { - this.open = open; + this.open = true; if (!this._showPopover()) { this.open = false; return false; } - state = await this._player.playExclusive(fadeIn()); + const state = await this._player.playExclusive(fadeIn()); this._setAutoHideTimer(); - } else { - clearTimeout(this._autoHideTimeout); - state = await this._player.playExclusive(fadeOut()); - this._hidePopover(); - this.open = open; + return state; } + clearTimeout(this._autoHideTimeout); + const state = await this._player.playExclusive(fadeOut()); + this._hidePopover(); + this.open = false; return state; } @@ -167,17 +185,32 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { } } - /** Opens the component. */ + /** + * Opens the component. + * + * Returns a promise that resolves to `true` if the component was successfully opened, or `false` + * if it was already open or could not be shown (e.g., in `container` positioning mode with no visible ancestors). + */ public async show(): Promise { return this.open ? false : this._setOpenState(true); } - /** Closes the component. */ + /** + * Closes the component. + * + * Returns a promise that resolves to `true` if the component was successfully closed, or `false` + * if it was already closed. + */ public async hide(): Promise { return this.open ? this._setOpenState(false) : false; } - /** Toggles the open state of the component. */ + /** + * Toggles the open state of the component. + * + * Returns a promise that resolves to `true` if the operation completed successfully, or `false` + * if it was already in the desired state. + */ public async toggle(): Promise { return this.open ? this.hide() : this.show(); } diff --git a/src/components/snackbar/snackbar.spec.ts b/src/components/snackbar/snackbar.spec.ts index 898563b73e..c1856d1bd4 100644 --- a/src/components/snackbar/snackbar.spec.ts +++ b/src/components/snackbar/snackbar.spec.ts @@ -98,11 +98,13 @@ describe('Snackbar', () => { const checkOpenState = (state = false) => { if (state) { expect(snackbar).dom.to.have.attribute('open'); + expect(snackbar.matches(':popover-open')).to.be.true; expect(snackbar).shadowDom.to.equal(`
`, { ignoreTags: ['span', 'slot'], }); } else { expect(snackbar).dom.not.to.have.attribute('open'); + expect(snackbar.matches(':popover-open')).to.be.false; expect(snackbar).shadowDom.to.equal(`
`, { ignoreTags: ['span', 'slot'], }); @@ -193,6 +195,83 @@ describe('Snackbar', () => { expect(snackbar.open).to.be.false; checkOpenState(false); }); + + describe('positioning', () => { + it('defaults to `viewport` with no inline anchor styles', async () => { + expect(snackbar.positioning).to.equal('viewport'); + + await snackbar.show(); + + expect(snackbar.matches(':popover-open')).to.be.true; + expect(snackbar.style.top).to.equal(''); + expect(snackbar.style.left).to.equal(''); + }); + + it('`container` positioning sets inline anchor styles when shown', async () => { + snackbar.positioning = 'container'; + await snackbar.show(); + + expect(snackbar.matches(':popover-open')).to.be.true; + expect(snackbar.style.top).to.not.equal(''); + expect(snackbar.style.left).to.not.equal(''); + }); + + it('`position` values map to the correct `top` anchor expression in `container` mode', async () => { + snackbar.positioning = 'container'; + await snackbar.show(); + + // default position is 'bottom' + expect(snackbar.style.top).to.equal('calc(-25% + anchor(bottom))'); + + snackbar.position = 'top'; + await elementUpdated(snackbar); + expect(snackbar.style.top).to.equal('anchor(top)'); + + snackbar.position = 'middle'; + await elementUpdated(snackbar); + expect(snackbar.style.top).to.equal('anchor(center)'); + }); + + it('switching `container → viewport` while open removes inline styles', async () => { + snackbar.positioning = 'container'; + await snackbar.show(); + + expect(snackbar.style.top).to.not.equal(''); + expect(snackbar.style.left).to.not.equal(''); + + snackbar.positioning = 'viewport'; + await elementUpdated(snackbar); + + expect(snackbar.matches(':popover-open')).to.be.true; + expect(snackbar.style.top).to.equal(''); + expect(snackbar.style.left).to.equal(''); + }); + + it('switching `viewport → container` while open sets inline styles', async () => { + await snackbar.show(); + + expect(snackbar.style.top).to.equal(''); + expect(snackbar.style.left).to.equal(''); + + snackbar.positioning = 'container'; + await elementUpdated(snackbar); + + expect(snackbar.matches(':popover-open')).to.be.true; + expect(snackbar.style.top).to.not.equal(''); + expect(snackbar.style.left).to.not.equal(''); + }); + + it('`position` changes in `viewport` mode do not set inline styles', async () => { + await snackbar.show(); + + snackbar.position = 'top'; + await elementUpdated(snackbar); + + expect(snackbar.matches(':popover-open')).to.be.true; + expect(snackbar.style.top).to.equal(''); + expect(snackbar.style.left).to.equal(''); + }); + }); }); describe('Events', () => { diff --git a/src/components/snackbar/snackbar.ts b/src/components/snackbar/snackbar.ts index 9eafb5fa5f..5bd4a69cb7 100644 --- a/src/components/snackbar/snackbar.ts +++ b/src/components/snackbar/snackbar.ts @@ -43,7 +43,7 @@ export default class IgcSnackbarComponent extends EventEmitterMixin< } /** - * The snackbar action button. + * The text of the action button. * @attr action-text */ @property({ attribute: 'action-text' }) diff --git a/src/components/toast/toast.spec.ts b/src/components/toast/toast.spec.ts index 0b00bd35e8..edc4586798 100644 --- a/src/components/toast/toast.spec.ts +++ b/src/components/toast/toast.spec.ts @@ -45,9 +45,11 @@ describe('Toast', () => { const checkOpenState = (state = false) => { if (state) { expect(toast).dom.to.have.attribute('open'); + expect(toast.matches(':popover-open')).to.be.true; expect(toast).shadowDom.to.equal(''); } else { expect(toast).dom.not.to.have.attribute('open'); + expect(toast.matches(':popover-open')).to.be.false; expect(toast).shadowDom.to.equal(''); } }; @@ -122,5 +124,66 @@ describe('Toast', () => { expect(toast.open).to.be.false; checkOpenState(false); }); + + describe('positioning', () => { + it('defaults to `viewport` with no inline anchor styles', async () => { + expect(toast.positioning).to.equal('viewport'); + + await toast.show(); + + expect(toast.matches(':popover-open')).to.be.true; + expect(toast.style.top).to.equal(''); + expect(toast.style.left).to.equal(''); + }); + + it('`container` positioning sets inline anchor styles when shown', async () => { + toast.positioning = 'container'; + await toast.show(); + + expect(toast.matches(':popover-open')).to.be.true; + expect(toast.style.top).to.not.equal(''); + expect(toast.style.left).to.not.equal(''); + }); + + it('switching `container → viewport` while open removes inline styles', async () => { + toast.positioning = 'container'; + await toast.show(); + + expect(toast.style.top).to.not.equal(''); + expect(toast.style.left).to.not.equal(''); + + toast.positioning = 'viewport'; + await elementUpdated(toast); + + expect(toast.matches(':popover-open')).to.be.true; + expect(toast.style.top).to.equal(''); + expect(toast.style.left).to.equal(''); + }); + + it('switching `viewport → container` while open sets inline styles', async () => { + await toast.show(); + + expect(toast.style.top).to.equal(''); + expect(toast.style.left).to.equal(''); + + toast.positioning = 'container'; + await elementUpdated(toast); + + expect(toast.matches(':popover-open')).to.be.true; + expect(toast.style.top).to.not.equal(''); + expect(toast.style.left).to.not.equal(''); + }); + + it('`position` changes in `viewport` mode do not set inline styles', async () => { + await toast.show(); + + toast.position = 'top'; + await elementUpdated(toast); + + expect(toast.matches(':popover-open')).to.be.true; + expect(toast.style.top).to.equal(''); + expect(toast.style.left).to.equal(''); + }); + }); }); }); diff --git a/src/components/toast/toast.ts b/src/components/toast/toast.ts index 1776418b06..2ab8cce6fd 100644 --- a/src/components/toast/toast.ts +++ b/src/components/toast/toast.ts @@ -11,6 +11,8 @@ import { styles } from './themes/toast.base.css.js'; * * @element igc-toast * + * @slot - Default slot for the toast content. + * * @csspart base - The base wrapper of the toast. */ export default class IgcToastComponent extends IgcBaseAlertLikeComponent {