Skip to content
Merged
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
123 changes: 100 additions & 23 deletions src/components/common/mixins/alert.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,68 @@
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
import { getVisibleAncestor, isPopoverOpen } from '../util.js';

export abstract class IgcBaseAlertLikeComponent extends LitElement {
declare protected abstract readonly _player: AnimationController;
protected readonly _player = addAnimationController(this);

protected _autoHideTimeout?: ReturnType<typeof setTimeout>;

/**
* 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 +74,69 @@ 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 && !isPopoverOpen(this)) {
this._showPopover();
} else if (!this.open && isPopoverOpen(this)) {
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.showPopover({ source: visibleAncestor });
return true;
}

private async _setOpenState(open: boolean): Promise<boolean> {
let state: 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 +147,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
31 changes: 31 additions & 0 deletions src/components/common/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,3 +669,34 @@ export function trimmedHtml(

return html(trimmedCache.get(strings)!, ...values);
}

/**
* Returns whether the given element is currently an open popover or not.
* This is useful to determine if the popover is open without relying on the `open` property, which may not be in sync with the actual popover state if the opening/closing animations are still running.
* Note: This function only works for elements that use the `:popover-open` pseudo-class to indicate
*/
export function isPopoverOpen(element?: Element): boolean {
return element?.matches(':popover-open') ?? false;
}

/**
* Returns the nearest visible ancestor of a given node, traversing through shadow DOM boundaries if necessary. If no visible ancestor is found, returns null.
*/
export 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;
}
25 changes: 13 additions & 12 deletions src/components/nav-drawer/nav-drawer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import type { TemplateResult } from 'lit';
import { spy } from 'sinon';
import { defineComponents } from '../common/definitions/defineComponents.js';
import { isPopoverOpen } from '../common/util.js';
import { simulateClick } from '../common/utils.spec.js';
import IgcIconComponent from '../icon/icon.js';
import IgcNavDrawerComponent from './nav-drawer.js';
Expand Down Expand Up @@ -354,14 +355,14 @@ describe('Navigation Drawer', () => {

it('is shown when the drawer is initially closed with mini content', async () => {
navDrawer = await createNavDrawerWithMini();
expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.true;
});

it('is hidden when the drawer opens', async () => {
navDrawer = await createNavDrawerWithMini();
await navDrawer.show();
await elementUpdated(navDrawer);
expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.false;
});

it('is shown again when the drawer closes', async () => {
Expand All @@ -370,22 +371,22 @@ describe('Navigation Drawer', () => {
await elementUpdated(navDrawer);
await navDrawer.hide();
await elementUpdated(navDrawer);
expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.true;
});

it('is not shown when there is no mini slot content', async () => {
navDrawer = await createNavDrawer();
expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.false;
});

it('is hidden when position changes to relative', async () => {
navDrawer = await createNavDrawerWithMini();
expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.true;

navDrawer.position = 'relative';
await elementUpdated(navDrawer);

expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.false;
});

it('is shown when position changes from relative to non-relative while closed', async () => {
Expand All @@ -395,36 +396,36 @@ describe('Navigation Drawer', () => {
</igc-nav-drawer>
`);

expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.false;

navDrawer.position = 'start';
await elementUpdated(navDrawer);

expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.true;
});

it('is shown when mini content is dynamically added', async () => {
navDrawer = await createNavDrawer();
expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.false;

const item = document.createElement('igc-nav-drawer-item');
item.slot = 'mini';
navDrawer.appendChild(item);

await waitUntil(
() => getMiniElement(navDrawer).matches(':popover-open'),
() => isPopoverOpen(getMiniElement(navDrawer)),
'Expected mini popover to be shown after adding mini content'
);
});

it('is hidden when all mini content is removed', async () => {
navDrawer = await createNavDrawerWithMini();
expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true;
expect(isPopoverOpen(getMiniElement(navDrawer))).to.be.true;

navDrawer.querySelector('[slot="mini"]')!.remove();

await waitUntil(
() => !getMiniElement(navDrawer).matches(':popover-open'),
() => !isPopoverOpen(getMiniElement(navDrawer)),
'Expected mini popover to be hidden after removing mini content'
);
});
Expand Down
17 changes: 10 additions & 7 deletions src/components/nav-drawer/nav-drawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { registerComponent } from '../common/definitions/register.js';
import type { Constructor } from '../common/mixins/constructor.js';
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
import { partMap } from '../common/part-map.js';
import { bindIf, numberInRangeInclusive } from '../common/util.js';
import {
bindIf,
isPopoverOpen,
numberInRangeInclusive,
} from '../common/util.js';
import type { NavDrawerPosition } from '../types.js';
import IgcNavDrawerHeaderItemComponent from './nav-drawer-header-item.js';
import IgcNavDrawerItemComponent from './nav-drawer-item.js';
Expand Down Expand Up @@ -150,9 +154,8 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin<
protected override update(properties: PropertyValues<this>): void {
if (properties.has('position') && this._isRelative) {
this._dialog?.close();
const mini = this._mini;
if (mini?.matches(':popover-open')) {
mini.hidePopover();
if (isPopoverOpen(this._mini)) {
this._mini?.hidePopover();
}
}

Expand Down Expand Up @@ -188,13 +191,13 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin<
return;
}

const isPopoverOpen = mini.matches(':popover-open');
const popOverOpen = isPopoverOpen(mini);

if (!this._hasMiniContent || this.open) {
if (isPopoverOpen) {
if (popOverOpen) {
mini.hidePopover();
}
} else if (!isPopoverOpen) {
} else if (!popOverOpen) {
mini.showPopover();
}
}
Expand Down
Loading
Loading