diff --git a/src/components/common/mixins/alert.ts b/src/components/common/mixins/alert.ts index 3796bc717..a03fc72c6 100644 --- a/src/components/common/mixins/alert.ts +++ b/src/components/common/mixins/alert.ts @@ -1,46 +1,98 @@ 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; + // 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 + * + * @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'; + constructor() { super(); @@ -52,29 +104,77 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { }); } - protected override updated(props: PropertyValues): void { - if (props.has('displayTime')) { + public override connectedCallback(): void { + super.connectedCallback(); + this.popover = 'manual'; + } + + protected override update(props: PropertyValues): void { + 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'))) { + this._hidePopover(); + this._showPopover(); + } + + if ( + props.has('open') || + props.has('displayTime') || + props.has('keepOpen') + ) { this._setAutoHideTimer(); } - if (props.has('keepOpen')) { - clearTimeout(this._autoHideTimeout); + super.update(props); + } + + private _showPopover(): boolean { + if (this.positioning !== 'container') { + 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 async _setOpenState(open: boolean): Promise { - let state: boolean; + private _hidePopover(): void { + this.hidePopover(); + this.style.removeProperty('top'); + this.style.removeProperty('left'); + } + private async _setOpenState(open: boolean): Promise { if (open) { - this.open = open; - state = await this._player.playExclusive(fadeIn()); + this.open = true; + + if (!this._showPopover()) { + this.open = false; + return false; + } + + const state = await this._player.playExclusive(fadeIn()); this._setAutoHideTimer(); - } else { - clearTimeout(this._autoHideTimeout); - state = await this._player.playExclusive(fadeOut()); - this.open = open; + return state; } + clearTimeout(this._autoHideTimeout); + const state = await this._player.playExclusive(fadeOut()); + this._hidePopover(); + this.open = false; return state; } @@ -85,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 898563b73..c1856d1bd 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 f6d66e41b..5bd4a69cb 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,14 +42,8 @@ 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. + * The text of the action button. * @attr action-text */ @property({ attribute: '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.spec.ts b/src/components/toast/toast.spec.ts index 0b00bd35e..edc458679 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 6d04700fa..2ab8cce6f 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'; @@ -12,14 +11,14 @@ 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 { 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 a8fb69559..f3daf2f26 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 d48a1431b..691d4a864 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 + +
+ `, +};