diff --git a/.vscode/cspell.json b/.vscode/cspell.json index faf1677550..c12ffbb122 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -11,6 +11,7 @@ "aria-valuenow", "aria-valuetext", "combobox", + "commandfor", "listbox", "listitem", "progressbar", @@ -32,9 +33,9 @@ "igniteui", "slotchange", "stylelint", - "webcomponents" - ], - "ignoreRegExpList": [ - "θ" + "webcomponents", + "noopener", + "noreferrer" ], + "ignoreRegExpList": ["θ"] } diff --git a/src/components/banner/banner.spec.ts b/src/components/banner/banner.spec.ts index f786930da9..fa05bb3f66 100644 --- a/src/components/banner/banner.spec.ts +++ b/src/components/banner/banner.spec.ts @@ -4,9 +4,11 @@ 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'; @@ -14,7 +16,7 @@ import IgcBannerComponent from './banner.js'; describe('Banner', () => { before(() => { - defineComponents(IgcBannerComponent, IgcIconComponent); + defineComponents(IgcBannerComponent, IgcButtonComponent, IgcIconComponent); }); const createDefaultBanner = () => html` @@ -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(html` +
+ Show + You are currently offline. +
+ `); + + invoker = container.querySelector('igc-button')!; + banner = container.querySelector('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(html` +
+ + You are currently offline. +
+ `); + + invoker = container.querySelector('button')!; + banner = container.querySelector('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; + }); + }); + }); }); diff --git a/src/components/banner/banner.ts b/src/components/banner/banner.ts index db130d9640..438aed7528 100644 --- a/src/components/banner/banner.ts +++ b/src/components/banner/banner.ts @@ -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'; @@ -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 ` `; } - private renderLinkButton() { + private _renderLinkButton() { return html` - ${this.renderContent()} + ${this._renderContent()} `; } - protected abstract renderContent(): TemplateResult; + protected abstract _renderContent(): TemplateResult; protected override render() { - const link = this.href !== undefined; - return link ? this.renderLinkButton() : this.renderButton(); + return this.href != null ? this._renderLinkButton() : this._renderButton(); } } diff --git a/src/components/button/button.spec.ts b/src/components/button/button.spec.ts index 590d8cf09c..4a5a772f42 100644 --- a/src/components/button/button.spec.ts +++ b/src/components/button/button.spec.ts @@ -217,6 +217,154 @@ describe('Button tests', () => { }); }); + describe('Invoker Commands API', () => { + describe('Attribute and property wiring', () => { + it('reflects the command attribute on the native button', async () => { + button = await fixture( + html`Click` + ); + const nativeButton = button.renderRoot.querySelector('button')!; + + expect(nativeButton).attribute('command').to.equal('toggle-popover'); + }); + + it('updates the command attribute when the property changes', async () => { + button = await fixture( + html`Click` + ); + const nativeButton = button.renderRoot.querySelector('button')!; + + button.command = 'show-popover'; + await elementUpdated(button); + + expect(nativeButton).attribute('command').to.equal('show-popover'); + }); + + it('resolves commandForElement from a string ID to the referenced element', async () => { + const container = await fixture(html` +
+ Click +
+
+ `); + + button = container.querySelector('igc-button')!; + const target = container.querySelector('#wiring-target')!; + + expect(button.commandForElement).to.equal(target); + }); + + it('accepts an Element reference for commandForElement', async () => { + const container = await fixture(html` +
+ Click +
+
+ `); + + button = container.querySelector('igc-button')!; + const target = container.querySelector('[popover]')!; + + button.commandForElement = target; + await elementUpdated(button); + + expect(button.commandForElement).to.equal(target); + }); + }); + + describe('Popover control', () => { + let popover: HTMLElement; + + beforeEach(async () => { + const container = await fixture(html` +
+ + Toggle + +
Popover content
+
+ `); + + button = container.querySelector('igc-button')!; + popover = container.querySelector('[popover]')!; + }); + + it('toggles a native popover on repeated clicks', () => { + expect(popover.matches(':popover-open')).to.be.false; + + button.click(); + expect(popover.matches(':popover-open')).to.be.true; + + button.click(); + expect(popover.matches(':popover-open')).to.be.false; + }); + + it('shows a closed native popover', async () => { + button.command = 'show-popover'; + await elementUpdated(button); + + expect(popover.matches(':popover-open')).to.be.false; + + button.click(); + expect(popover.matches(':popover-open')).to.be.true; + }); + + it('hides a visible native popover', async () => { + button.command = 'hide-popover'; + await elementUpdated(button); + + popover.showPopover(); + expect(popover.matches(':popover-open')).to.be.true; + + button.click(); + expect(popover.matches(':popover-open')).to.be.false; + }); + }); + + describe('Dialog control', () => { + let dialog: HTMLDialogElement; + + beforeEach(async () => { + const container = await fixture(html` +
+ + Open + + Dialog content +
+ `); + + button = container.querySelector('igc-button')!; + dialog = container.querySelector('dialog')!; + }); + + afterEach(() => { + // Ensure dialog is closed between tests to avoid InvalidStateError + if (dialog.open) { + dialog.close(); + } + }); + + it('opens a native dialog as modal', () => { + expect(dialog.open).to.be.false; + + button.click(); + expect(dialog.open).to.be.true; + }); + + it('closes an open native dialog', async () => { + dialog.showModal(); + expect(dialog.open).to.be.true; + + button.command = 'close'; + await elementUpdated(button); + + button.click(); + expect(dialog.open).to.be.false; + }); + }); + }); + describe('Form integration', () => { let button: IgcButtonComponent; const spec = createFormAssociatedTestBed(html` diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 318c019aef..aa32741fd4 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -13,6 +13,10 @@ import { all } from './themes/button/themes.js'; * Represents a clickable button, used to submit forms or anywhere in a * document for accessible, standard button functionality. * + * The button supports multiple visual variants, can render as an anchor + * (``) element when the `href` attribute is set, and is fully + * form-associated, acting as a native `submit` or `reset` control. + * * @element igc-button * * @slot - Renders the label of the button. @@ -22,6 +26,35 @@ import { all } from './themes/button/themes.js'; * @csspart base - The native button element of the igc-button component. * @csspart prefix - The prefix container of the igc-button component. * @csspart suffix - The suffix container of the igc-button component. + * + * @example + * + * Click me + * + * @example + * + * + * + * Search + * + * + * + * @example + * + * + * Open link + * + * + * @example + * + * + * Submit form + * Reset + * + * + * @example + * + * Unavailable */ export default class IgcButtonComponent extends IgcButtonBaseComponent { public static readonly tagName = 'igc-button'; @@ -33,8 +66,13 @@ export default class IgcButtonComponent extends IgcButtonBaseComponent { } /** - * Sets the variant of the button. - * @attr + * The variant of the button which determines its visual appearance. + * - `contained` – filled background; highest visual emphasis (default). + * - `outlined` – transparent background with a visible border. + * - `flat` – no background or border; lowest visual emphasis. + * - `fab` – floating action button shape; typically used for primary actions. + * @attr variant + * @default 'contained' */ @property({ reflect: true }) public variant: ButtonVariant = 'contained'; @@ -44,7 +82,7 @@ export default class IgcButtonComponent extends IgcButtonBaseComponent { addThemingController(this, all); } - protected renderContent() { + protected _renderContent() { return html` diff --git a/src/components/button/icon-button.ts b/src/components/button/icon-button.ts index 8e5b9fbe3c..6c91e98381 100644 --- a/src/components/button/icon-button.ts +++ b/src/components/button/icon-button.ts @@ -17,10 +17,50 @@ import { styles as shared } from './themes/icon-button/shared/icon-button.common import { all } from './themes/icon-button/themes.js'; /** + * A button that displays a single icon, designed for compact, icon-only + * interactions such as toolbar actions, floating action buttons, or inline + * controls. + * + * The icon is sourced from the icon registry via the `name` and `collection` + * attributes. Like `igc-button`, it can render as an anchor element when + * `href` is set and is fully form-associated. + * * @element igc-icon-button * + * @slot - Optional label rendered alongside the icon, useful for + * accessibility or augmented layouts. + * * @csspart base - The wrapping element of the icon button. * @csspart icon - The icon element of the icon button. + * + * @example + * + * + * + * @example + * + * + * + * @example + * + * + * + * @example + * + * + * + * @example + * + * */ export default class IgcIconButtonComponent extends IgcButtonBaseComponent { public static readonly tagName = 'igc-icon-button'; @@ -33,29 +73,34 @@ export default class IgcIconButtonComponent extends IgcButtonBaseComponent { /* alternateName: iconName */ /** - * The name of the icon. - * @attr + * The name of the icon to display. + * @attr name */ @property() public name?: string; /** - * The name of the icon collection. - * @attr + * The collection the icon belongs to. + * @attr collection */ @property() public collection?: string; /** - * Whether to flip the icon button. Useful for RTL layouts. - * @attr + * Determines whether the icon should be mirrored in right-to-left contexts. + * @attr mirrored + * @default false */ @property({ type: Boolean }) public mirrored = false; /** - * The visual variant of the icon button. - * @attr + * The variant of the button which determines its visual appearance. + * - `contained` – filled background; highest visual emphasis (default). + * - `outlined` – transparent background with a visible border. + * - `flat` – no background or border; lowest visual emphasis. + * @attr variant + * @default 'contained' */ @property({ reflect: true }) public variant: IconButtonVariant = 'contained'; @@ -65,7 +110,7 @@ export default class IgcIconButtonComponent extends IgcButtonBaseComponent { addThemingController(this, all); } - protected renderContent() { + protected _renderContent() { return html` ${this.name || this.mirrored ? html` @@ -73,7 +118,7 @@ export default class IgcIconButtonComponent extends IgcButtonBaseComponent { part="icon" name=${ifDefined(this.name)} collection=${ifDefined(this.collection)} - .mirrored=${this.mirrored} + ?mirrored=${this.mirrored} aria-hidden="true" > diff --git a/src/components/common/controllers/command.ts b/src/components/common/controllers/command.ts new file mode 100644 index 0000000000..1c5c10392f --- /dev/null +++ b/src/components/common/controllers/command.ts @@ -0,0 +1,85 @@ +import type { LitElement, ReactiveController } from 'lit'; + +/** + * A Lit reactive controller that bridges the native + * [Invoker Commands API](https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API) + * with a component's programmatic API. + * + * When an `igc-button` (or any element using the `command` / `commandfor` + * attributes) invokes a command on the host, the browser dispatches a + * `CommandEvent` on the target element. This controller listens for that + * event and forwards it to the registered callback for the given command + * string. + * + * @example + * ```ts + * class IgcDialogComponent extends LitElement { + * private readonly _commands = addCommandController(this) + * .set('open', this.show) + * .set('close', this.hide) + * .set('toggle-popover', this.toggle); + * } + * ``` + * + * With the above setup, a button in the document can control the dialog + * declaratively: + * + * ```html + * Open + * + * ``` + */ +class CommandController implements ReactiveController { + private readonly _host: LitElement; + private readonly _commandMap = new Map unknown>(); + + constructor(host: LitElement) { + this._host = host; + host.addController(this); + } + + /** + * Registers a command string and its corresponding handler callback. + * + * Returns `this` to allow chained calls: + * ```ts + * addCommandController(this) + * .set('open', this.show) + * .set('close', this.hide); + * ``` + * + * @param command - The command string to listen for (e.g. `'open'`, + * `'toggle-popover'`, or a custom `'--my-command'`). + * @param callback - The method to invoke when the command is received. + * Called with the host as `this`. + */ + public set(command: string, callback: () => unknown): this { + this._commandMap.set(command, callback); + return this; + } + + /** @internal */ + public hostConnected(): void { + this._host.addEventListener('command', this); + } + + /** @internal */ + public hostDisconnected(): void { + this._host.removeEventListener('command', this); + } + + /** @internal */ + public handleEvent(event: Event): void { + const commandEvent = event as CommandEvent; + this._commandMap.get(commandEvent.command)?.call(this._host); + } +} + +/** + * Creates a {@link CommandController} and attaches it to the given host. + */ +export function addCommandController(host: LitElement): CommandController { + return new CommandController(host); +} + +export type { CommandController }; diff --git a/src/components/dialog/dialog.spec.ts b/src/components/dialog/dialog.spec.ts index 6403ccbc1b..dcc9efa737 100644 --- a/src/components/dialog/dialog.spec.ts +++ b/src/components/dialog/dialog.spec.ts @@ -279,6 +279,164 @@ describe('Dialog', () => { }); }); + describe('Invoker Commands API', () => { + afterEach(async () => { + if (dialog.open) { + await dialog.hide(); + } + }); + + describe('with igc-button', () => { + let invoker: IgcButtonComponent; + + beforeEach(async () => { + const container = await fixture(html` +
+ Open + +
+ `); + + invoker = container.querySelector('igc-button')!; + dialog = container.querySelector('igc-dialog')!; + }); + + it('`--show` opens the dialog', async () => { + expect(dialog.open).to.be.false; + + invoker.click(); + await waitUntil(() => dialog.open); + + expect(dialog.open).to.be.true; + }); + + it('`--hide` closes an open dialog', async () => { + await dialog.show(); + expect(dialog.open).to.be.true; + + invoker.command = '--hide'; + await elementUpdated(invoker); + + invoker.click(); + await waitUntil(() => !dialog.open); + + expect(dialog.open).to.be.false; + }); + + it('`--toggle` opens a closed dialog', async () => { + expect(dialog.open).to.be.false; + + invoker.command = '--toggle'; + await elementUpdated(invoker); + + invoker.click(); + await waitUntil(() => dialog.open); + + expect(dialog.open).to.be.true; + }); + + it('`--toggle` closes an open dialog', async () => { + await dialog.show(); + expect(dialog.open).to.be.true; + + invoker.command = '--toggle'; + await elementUpdated(invoker); + + invoker.click(); + await waitUntil(() => !dialog.open); + + expect(dialog.open).to.be.false; + }); + + it('a disabled igc-button does not invoke commands', async () => { + invoker.disabled = true; + await elementUpdated(invoker); + + invoker.click(); + await elementUpdated(dialog); + + expect(dialog.open).to.be.false; + }); + }); + + describe('with native button', () => { + let invoker: HTMLButtonElement; + + beforeEach(async () => { + const container = await fixture(html` +
+ + +
+ `); + + invoker = container.querySelector('button')!; + dialog = container.querySelector('igc-dialog')!; + + invoker.setAttribute('command', '--show'); + invoker.setAttribute('commandfor', 'native-invoker-dialog'); + }); + + it('`--show` opens the dialog', async () => { + expect(dialog.open).to.be.false; + + invoker.click(); + await waitUntil(() => dialog.open); + + expect(dialog.open).to.be.true; + }); + + it('`--hide` closes an open dialog', async () => { + await dialog.show(); + expect(dialog.open).to.be.true; + + invoker.setAttribute('command', '--hide'); + + invoker.click(); + await waitUntil(() => !dialog.open); + + expect(dialog.open).to.be.false; + }); + + it('`--toggle` opens a closed dialog', async () => { + expect(dialog.open).to.be.false; + + invoker.setAttribute('command', '--toggle'); + + invoker.click(); + await waitUntil(() => dialog.open); + + expect(dialog.open).to.be.true; + }); + + it('`--toggle` closes an open dialog', async () => { + await dialog.show(); + expect(dialog.open).to.be.true; + + invoker.setAttribute('command', '--toggle'); + + invoker.click(); + await waitUntil(() => !dialog.open); + + expect(dialog.open).to.be.false; + }); + + it('a disabled native button does not invoke commands', async () => { + invoker.disabled = true; + + invoker.click(); + await elementUpdated(dialog); + + expect(dialog.open).to.be.false; + }); + }); + }); + describe('Form', () => { beforeEach(async () => { dialog = await fixture(html` diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index d636c51e55..520a6e6f23 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -1,12 +1,12 @@ -import { html, LitElement, nothing } from 'lit'; +import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { property, state } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { addAnimationController } from '../../animations/player.js'; import { fadeIn, fadeOut } from '../../animations/presets/fade/index.js'; import { addThemingController } from '../../theming/theming-controller.js'; import IgcButtonComponent from '../button/button.js'; +import { addCommandController } from '../common/controllers/command.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; -import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; @@ -25,22 +25,68 @@ let nextId = 1; /* blazorAdditionalDependency: IgcButtonComponent */ /** - * Represents a Dialog component. + * A modal dialog component built on the native `` element. + * + * The dialog traps focus while open and blocks interaction with the rest + * of the page (modal semantics). It supports animated open/close + * transitions, an optional backdrop overlay, and multiple content areas + * through named slots. + * + * 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 `