Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 137 additions & 22 deletions src/components/common/mixins/alert.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>;

// 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';

Comment thread
rkaraivanov marked this conversation as resolved.
/**
* 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();

Expand All @@ -52,29 +104,77 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement {
});
}

protected override updated(props: PropertyValues<this>): void {
if (props.has('displayTime')) {
public override connectedCallback(): void {
super.connectedCallback();
this.popover = 'manual';
}

protected override update(props: PropertyValues<this>): 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<boolean> {
let state: boolean;
private _hidePopover(): void {
this.hidePopover();
this.style.removeProperty('top');
this.style.removeProperty('left');
}

private async _setOpenState(open: boolean): Promise<boolean> {
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;
}

Expand All @@ -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<boolean> {
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<boolean> {
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<boolean> {
return this.open ? this.hide() : this.show();
}
Expand Down
79 changes: 79 additions & 0 deletions src/components/snackbar/snackbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div part="base"></div>`, {
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(`<div part="base" inert></div>`, {
ignoreTags: ['span', 'slot'],
});
Expand Down Expand Up @@ -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', () => {
Expand Down
20 changes: 7 additions & 13 deletions src/components/snackbar/snackbar.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,14 +42,8 @@ export default class IgcSnackbarComponent extends EventEmitterMixin<
registerComponent(IgcSnackbarComponent, IgcButtonComponent);
}

protected readonly _contentRef = createRef<HTMLElement>();
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' })
Expand All @@ -68,16 +60,18 @@ export default class IgcSnackbarComponent extends EventEmitterMixin<

protected override render() {
return html`
<div ${ref(this._contentRef)} part="base" .inert=${!this.open}>
<div part="base" .inert=${!this.open}>
<span part="message">
<slot></slot>
</span>

<slot name="action" part="action-container" @click=${this._handleClick}>
${this.actionText
? html`<igc-button type="button" part="action" variant="flat">
${this.actionText}
</igc-button>`
? html`
<igc-button type="button" part="action" variant="flat">
${this.actionText}
</igc-button>
`
: nothing}
</slot>
</div>
Expand Down
Loading
Loading