Skip to content
Draft
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
9 changes: 5 additions & 4 deletions .vscode/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"aria-valuenow",
"aria-valuetext",
"combobox",
"commandfor",
"listbox",
"listitem",
"progressbar",
Expand All @@ -32,9 +33,9 @@
"igniteui",
"slotchange",
"stylelint",
"webcomponents"
],
"ignoreRegExpList": [
"θ"
"webcomponents",
"noopener",
"noreferrer"
],
"ignoreRegExpList": ["θ"]
}
163 changes: 162 additions & 1 deletion src/components/banner/banner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import {
fixture,
html,
nextFrame,
waitUntil,
} from '@open-wc/testing';
import { spy } from 'sinon';

import IgcButtonComponent from '../button/button.js';
import { defineComponents } from '../common/definitions/defineComponents.js';
import { finishAnimationsFor, simulateClick } from '../common/utils.spec.js';
import IgcIconComponent from '../icon/icon.js';
import IgcBannerComponent from './banner.js';

describe('Banner', () => {
before(() => {
defineComponents(IgcBannerComponent, IgcIconComponent);
defineComponents(IgcBannerComponent, IgcButtonComponent, IgcIconComponent);
});

const createDefaultBanner = () => html`
Expand Down Expand Up @@ -287,4 +289,163 @@ describe('Banner', () => {
expect(banner.open).to.be.true;
});
});

describe('Invoker Commands API', () => {
afterEach(async () => {
if (banner.open) {
await banner.hide();
}
});

describe('with igc-button', () => {
let invoker: IgcButtonComponent;

beforeEach(async () => {
const container = await fixture<HTMLElement>(html`
<div>
<igc-button command="--show" commandfor="invoker-banner"
>Show</igc-button
>
<igc-banner id="invoker-banner"
>You are currently offline.</igc-banner
>
</div>
`);

invoker = container.querySelector<IgcButtonComponent>('igc-button')!;
banner = container.querySelector<IgcBannerComponent>('igc-banner')!;
});

it('`--show` opens the banner', async () => {
expect(banner.open).to.be.false;

invoker.click();
await waitUntil(() => banner.open);

expect(banner.open).to.be.true;
});

it('`--hide` closes an open banner', async () => {
await banner.show();
expect(banner.open).to.be.true;

invoker.command = '--hide';
await elementUpdated(invoker);

invoker.click();
await waitUntil(() => !banner.open);

expect(banner.open).to.be.false;
});

it('`--toggle` opens a closed banner', async () => {
expect(banner.open).to.be.false;

invoker.command = '--toggle';
await elementUpdated(invoker);

invoker.click();
await waitUntil(() => banner.open);

expect(banner.open).to.be.true;
});

it('`--toggle` closes an open banner', async () => {
await banner.show();
expect(banner.open).to.be.true;

invoker.command = '--toggle';
await elementUpdated(invoker);

invoker.click();
await waitUntil(() => !banner.open);

expect(banner.open).to.be.false;
});

it('a disabled igc-button does not invoke commands', async () => {
invoker.disabled = true;
await elementUpdated(invoker);

invoker.click();
await elementUpdated(banner);

expect(banner.open).to.be.false;
});
});

describe('with native button', () => {
let invoker: HTMLButtonElement;

beforeEach(async () => {
const container = await fixture<HTMLElement>(html`
<div>
<button>Show</button>
<igc-banner id="native-invoker-banner"
>You are currently offline.</igc-banner
>
</div>
`);

invoker = container.querySelector<HTMLButtonElement>('button')!;
banner = container.querySelector<IgcBannerComponent>('igc-banner')!;

invoker.setAttribute('command', '--show');
invoker.setAttribute('commandfor', 'native-invoker-banner');
});

it('`--show` opens the banner', async () => {
expect(banner.open).to.be.false;

invoker.click();
await waitUntil(() => banner.open);

expect(banner.open).to.be.true;
});

it('`--hide` closes an open banner', async () => {
await banner.show();
expect(banner.open).to.be.true;

invoker.setAttribute('command', '--hide');

invoker.click();
await waitUntil(() => !banner.open);

expect(banner.open).to.be.false;
});

it('`--toggle` opens a closed banner', async () => {
expect(banner.open).to.be.false;

invoker.setAttribute('command', '--toggle');

invoker.click();
await waitUntil(() => banner.open);

expect(banner.open).to.be.true;
});

it('`--toggle` closes an open banner', async () => {
await banner.show();
expect(banner.open).to.be.true;

invoker.setAttribute('command', '--toggle');

invoker.click();
await waitUntil(() => !banner.open);

expect(banner.open).to.be.false;
});

it('a disabled native button does not invoke commands', async () => {
invoker.disabled = true;

invoker.click();
await elementUpdated(banner);

expect(banner.open).to.be.false;
});
});
});
});
98 changes: 80 additions & 18 deletions src/components/banner/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { addAnimationController } from '../../animations/player.js';
import { growVerIn, growVerOut } from '../../animations/presets/grow/index.js';
import { addThemingController } from '../../theming/theming-controller.js';
import IgcButtonComponent from '../button/button.js';
import { addCommandController } from '../common/controllers/command.js';
import { addInternalsController } from '../common/controllers/internals.js';
import { addSlotController, setSlots } from '../common/controllers/slot.js';
import { registerComponent } from '../common/definitions/register.js';
Expand All @@ -19,25 +20,60 @@ export interface IgcBannerComponentEventMap {
}

/**
* The `igc-banner` component displays important and concise message(s) for a user to address, that is specific to a page or feature.
* A non-modal notification banner that displays important, concise messages
* requiring user acknowledgement.
*
* The banner slides into view with an animated grow transition and renders
* inline, pushing the surrounding page content rather than overlaying it.
* It provides a default "OK" dismiss action that fires `igcClosing` /
* `igcClosed`, and supports custom action content through the `actions` slot.
*
* The component integrates with the
* [Invoker Commands API](https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API):
* an `igc-button` or a native `<button>` with `command="--show"` / `"--hide"` /
* `"--toggle"` and `commandfor` pointing to this element will call the
* corresponding method declaratively without any JavaScript.
*
* @element igc-banner
*
* @slot - Renders the text content of the banner message.
* @slot prefix - Renders additional content at the start of the message block.
* @slot actions - Renders any action elements.
* @fires igcClosing - Emitted just before the banner closes in response to the
* default action button being clicked. Cancelable — call
* `event.preventDefault()` to abort the closing sequence.
* @fires igcClosed - Emitted after the banner has fully closed and its exit
* animation has completed.
*
* @slot - The banner message text content.
* @slot prefix - An icon or illustration rendered to the left of the message.
* Useful for reinforcing the message type (info, warning, success, etc.).
* @slot actions - Custom action elements rendered in the banner's action area.
* When provided, replaces the default "OK" dismiss button.
*
* @fires igcClosing - Emitted before closing the banner - when a user interacts (click) with the default action of the banner.
* @fires igcClosed - Emitted after the banner is closed - when a user interacts (click) with the default action of the banner.
* @csspart base - The root wrapper element of the banner.
* @csspart spacer - The inner wrapper that controls the spacing around the banner content.
* @csspart message - The container that holds the illustration and text content.
* @csspart illustration - The container for the prefix slot (icon/illustration).
* @csspart content - The container for the default message slot.
* @csspart actions - The container for the action buttons slot.
*
* @csspart base - The base wrapper of the banner component.
* @csspart spacer - The inner wrapper that sets the space around the banner.
* @csspart message - The part that holds the text and the illustration.
* @csspart illustration - The part that holds the banner icon/illustration.
* @csspart content - The part that holds the banner text content.
* @csspart actions - The part that holds the banner action buttons.
* @example
* <!-- Basic banner with a custom action -->
* <igc-banner id="banner">
* You are currently offline. Check your connection.
* <div slot="actions">
* <igc-button onclick="banner.hide()">Dismiss</igc-button>
* <igc-button onclick="banner.hide()">Retry</igc-button>
* </div>
* </igc-banner>
* <igc-button onclick="banner.show()">Show Banner</igc-button>
*
* @example
* <!-- Declarative control via the Invoker Commands API -->
* <igc-button command="--toggle" commandfor="status-banner">Toggle</igc-button>
* <igc-banner id="status-banner">
* <igc-icon slot="prefix" name="warning"></igc-icon>
* Your session is about to expire.
* </igc-banner>
*/

export default class IgcBannerComponent extends EventEmitterMixin<
IgcBannerComponentEventMap,
Constructor<LitElement>
Expand All @@ -54,8 +90,15 @@ export default class IgcBannerComponent extends EventEmitterMixin<
private readonly _player = addAnimationController(this, this._bannerRef);

/**
* Determines whether the banner is being shown/hidden.
* @attr
* Whether the banner is open.
*
* Setting this property programmatically will immediately show or hide the
* banner without animation and without emitting `igcClosing` / `igcClosed`.
* Prefer the `show()`, `hide()`, and `toggle()` methods for animated
* transitions. Events are only emitted when the banner is closed through
* user interaction with the default action button.
* @attr open
* @default false
*/
@property({ type: Boolean, reflect: true })
public open = false;
Expand All @@ -71,6 +114,10 @@ export default class IgcBannerComponent extends EventEmitterMixin<
ariaLive: 'polite',
},
});
addCommandController(this)
.set('--show', this.show)
.set('--hide', this.hide)
.set('--toggle', this.toggle);
}

private async _handleClick(): Promise<void> {
Expand All @@ -80,7 +127,12 @@ export default class IgcBannerComponent extends EventEmitterMixin<
}
}

/** Shows the banner if not already shown. Returns `true` when the animation has completed. */
/**
* Opens the banner with an animated grow-in transition.
*
* Returns `true` when the banner was successfully opened, or `false` if
* it was already open.
*/
public async show(): Promise<boolean> {
if (this.open) {
return false;
Expand All @@ -90,7 +142,12 @@ export default class IgcBannerComponent extends EventEmitterMixin<
return this._player.playExclusive(growVerIn());
}

/** Hides the banner if not already hidden. Returns `true` when the animation has completed. */
/**
* Closes the banner with an animated grow-out transition.
*
* Returns `true` when the banner was successfully closed, or `false` if
* it was already closed.
*/
public async hide(): Promise<boolean> {
if (!this.open) {
return false;
Expand All @@ -101,7 +158,12 @@ export default class IgcBannerComponent extends EventEmitterMixin<
return true;
}

/** Toggles between shown/hidden state. Returns `true` when the animation has completed. */
/**
* Toggles the banner open or closed depending on its current state.
*
* Equivalent to calling `show()` when closed and `hide()` when open.
* Returns `true` when the transition completed successfully.
*/
public async toggle(): Promise<boolean> {
return this.open ? this.hide() : this.show();
}
Expand Down
Loading
Loading