Skip to content
Open
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": ["θ"]
}
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `keepOpenOnEscape` property — prevents the drawer from closing when the user presses the **Escape** key (non-relative positions only).
- Added `igcClosing` event — emitted just before the drawer is closed by user interaction. Cancelable.
- Added `igcClosed` event — emitted just after the drawer is closed by user interaction.
- #### Invoker Commands API
- `igc-button` and `igc-icon-button` now support `command` and `commandfor` properties, enabling declarative control of target components without JavaScript.
- `igc-banner`, `igc-dialog`, `igc-nav-drawer`, `igc-snackbar`, and `igc-toast` now respond to `--show`, `--hide`, and `--toggle` commands dispatched by an invoker button.

### Changed
- #### Nav Drawer
Expand Down
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