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/CHANGELOG.md b/CHANGELOG.md index bf4c7c603e..357b23a000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..e97ab6d8cd 100644 --- a/src/components/button/button.spec.ts +++ b/src/components/button/button.spec.ts @@ -217,6 +217,171 @@ 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); + }); + + it('resolves commandfor when target is appended to the DOM after initial render', async () => { + button = await fixture( + html`Click` + ); + + expect(button.commandForElement).to.be.null; + + const target = document.createElement('div'); + target.id = 'dynamic-target'; + document.body.appendChild(target); + await elementUpdated(button); + + expect(button.commandForElement).to.equal(target); + + target.remove(); + }); + }); + + 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/common/controllers/id-resolver.spec.ts b/src/components/common/controllers/id-resolver.spec.ts new file mode 100644 index 0000000000..9835728679 --- /dev/null +++ b/src/components/common/controllers/id-resolver.spec.ts @@ -0,0 +1,403 @@ +import { + defineCE, + elementUpdated, + expect, + fixture, + html, + unsafeStatic, +} from '@open-wc/testing'; +import { LitElement } from 'lit'; +import { + addIdRefResolver, + type IdRefResolverController, +} from './id-resolver.js'; + +// Shared host definition (registered once) +type HostInstance = LitElement & { + resolver: IdRefResolverController; + receivedIds: Set | null; + callCount: number; + capturedThis: unknown; +}; + +// Second host definition for multi-controller tests +type SecondHostInstance = LitElement & { + resolver: IdRefResolverController; + callCount: number; +}; + +describe('IdRefResolverController', () => { + let tag: string; + let instance: HostInstance; + + before(() => { + tag = defineCE( + class extends LitElement { + public receivedIds: Set | null = null; + public callCount = 0; + public capturedThis: unknown = null; + + public readonly resolver = addIdRefResolver( + this, + function (this: LitElement, ids: Set) { + (this as unknown as HostInstance).receivedIds = new Set(ids); + (this as unknown as HostInstance).callCount++; + (this as unknown as HostInstance).capturedThis = this; + this.requestUpdate(); + } + ); + } + ); + }); + + beforeEach(async () => { + const tagName = unsafeStatic(tag); + instance = await fixture(html`<${tagName}>`); + }); + + describe('resolve(id)', () => { + let target: HTMLDivElement; + + afterEach(() => { + target?.remove(); + }); + + it('returns null when no element with that ID exists', () => { + expect(instance.resolver.resolve('nonexistent')).to.be.null; + }); + + it('resolves an element present in the document by ID', () => { + target = document.createElement('div'); + target.id = 'resolve-target'; + document.body.appendChild(target); + + expect(instance.resolver.resolve('resolve-target')).to.equal(target); + }); + + it('returns null after the element is removed', () => { + target = document.createElement('div'); + target.id = 'removed-target'; + document.body.appendChild(target); + + expect(instance.resolver.resolve('removed-target')).to.not.be.null; + + target.remove(); + expect(instance.resolver.resolve('removed-target')).to.be.null; + }); + }); + + describe('observe() / unobserve()', () => { + let added: HTMLDivElement; + + afterEach(() => { + added?.remove(); + instance.resolver.unobserve(); + }); + + it('callback never fires when observe() was not called', async () => { + added = document.createElement('div'); + added.id = 'no-observe'; + document.body.appendChild(added); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(0); + }); + + it('observe() is idempotent — calling it twice yields one callback per mutation', async () => { + instance.resolver.observe(); + instance.resolver.observe(); + + added = document.createElement('div'); + added.id = 'idempotent'; + document.body.appendChild(added); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(1); + }); + + it('after unobserve(), callback stops firing', async () => { + instance.resolver.observe(); + + added = document.createElement('div'); + added.id = 'first-add'; + document.body.appendChild(added); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(1); + + instance.resolver.unobserve(); + + const second = document.createElement('div'); + second.id = 'second-add'; + document.body.appendChild(second); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(1); + + second.remove(); + }); + + it('unobserve() is safe when not observing', () => { + expect(() => instance.resolver.unobserve()).to.not.throw(); + }); + }); + + describe('DOM mutation detection', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + instance.resolver.observe(); + }); + + afterEach(() => { + container.remove(); + instance.resolver.unobserve(); + }); + + it('adding element with an id fires callback with that ID', async () => { + const el = document.createElement('div'); + el.id = 'added-foo'; + container.appendChild(el); + await elementUpdated(instance); + + expect(instance.callCount).to.be.greaterThan(0); + expect(instance.receivedIds!.has('added-foo')).to.be.true; + }); + + it('removing element with an id fires callback with that ID', async () => { + const el = document.createElement('div'); + el.id = 'removed-bar'; + container.appendChild(el); + await elementUpdated(instance); + + instance.callCount = 0; + + el.remove(); + await elementUpdated(instance); + + expect(instance.callCount).to.be.greaterThan(0); + expect(instance.receivedIds!.has('removed-bar')).to.be.true; + }); + + it('changing an element id attribute fires for both old and new IDs', async () => { + const el = document.createElement('div'); + el.id = 'old-id'; + container.appendChild(el); + await elementUpdated(instance); + + instance.callCount = 0; + + el.id = 'new-id'; + await elementUpdated(instance); + + expect(instance.callCount).to.be.greaterThan(0); + expect(instance.receivedIds!.has('old-id')).to.be.true; + expect(instance.receivedIds!.has('new-id')).to.be.true; + }); + + it('adding a subtree with nested [id] descendants fires for all nested IDs', async () => { + const parent = document.createElement('div'); + const childA = document.createElement('span'); + const childB = document.createElement('span'); + childA.id = 'nested-a'; + childB.id = 'nested-b'; + parent.appendChild(childA); + parent.appendChild(childB); + + container.appendChild(parent); + await elementUpdated(instance); + + expect(instance.receivedIds!.has('nested-a')).to.be.true; + expect(instance.receivedIds!.has('nested-b')).to.be.true; + }); + + it('DOM mutations with no ID-bearing nodes do not fire callback', async () => { + const el = document.createElement('div'); + container.appendChild(el); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(0); + }); + }); + + describe('host lifecycle', () => { + let el: HTMLDivElement; + + afterEach(() => { + el?.remove(); + instance.resolver.unobserve(); + if (!instance.isConnected) { + document.body.appendChild(instance); + } + }); + + it('observe() called before hostConnected activates on connect', async () => { + const detached = document.createElement(tag) as HostInstance; + detached.resolver.observe(); + + document.body.appendChild(detached); + await elementUpdated(detached); + + el = document.createElement('div'); + el.id = 'before-connect'; + document.body.appendChild(el); + await elementUpdated(detached); + + expect(detached.callCount).to.be.greaterThan(0); + expect(detached.receivedIds!.has('before-connect')).to.be.true; + + detached.remove(); + }); + + it('callback is suspended while host is disconnected', async () => { + instance.resolver.observe(); + instance.remove(); + + el = document.createElement('div'); + el.id = 'during-disconnect'; + document.body.appendChild(el); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(0); + }); + + it('observation resumes after host is reconnected', async () => { + instance.resolver.observe(); + instance.remove(); + + el = document.createElement('div'); + el.id = 'during-disconnect'; + document.body.appendChild(el); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(0); + + document.body.appendChild(instance); + await elementUpdated(instance); + + const el2 = document.createElement('div'); + el2.id = 'after-reconnect'; + document.body.appendChild(el2); + await elementUpdated(instance); + + expect(instance.callCount).to.be.greaterThan(0); + expect(instance.receivedIds!.has('after-reconnect')).to.be.true; + + el2.remove(); + }); + + it('unobserve() during disconnect does not resume on reconnect', async () => { + instance.resolver.observe(); + instance.remove(); + instance.resolver.unobserve(); + + document.body.appendChild(instance); + await elementUpdated(instance); + + el = document.createElement('div'); + el.id = 'after-unobserve-reconnect'; + document.body.appendChild(el); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(0); + }); + }); + + describe('callback context', () => { + let el: HTMLDivElement; + + afterEach(() => { + el?.remove(); + instance.resolver.unobserve(); + }); + + it('this inside callback is bound to the host element', async () => { + instance.resolver.observe(); + + el = document.createElement('div'); + el.id = 'context-check'; + document.body.appendChild(el); + await elementUpdated(instance); + + expect(instance.capturedThis).to.equal(instance); + }); + }); + + describe('reference counting', () => { + let secondTag: string; + let second: SecondHostInstance; + let el: HTMLDivElement; + + before(() => { + secondTag = defineCE( + class extends LitElement { + public callCount = 0; + public readonly resolver = addIdRefResolver( + this, + function (this: LitElement) { + (this as unknown as SecondHostInstance).callCount++; + this.requestUpdate(); + } + ); + } + ); + }); + + beforeEach(async () => { + const tagName = unsafeStatic(secondTag); + second = await fixture( + html`<${tagName}>` + ); + }); + + afterEach(() => { + el?.remove(); + instance.resolver.unobserve(); + second.resolver.unobserve(); + }); + + it('two controllers both observing both receive callbacks', async () => { + instance.resolver.observe(); + second.resolver.observe(); + + el = document.createElement('div'); + el.id = 'shared-both'; + document.body.appendChild(el); + await Promise.all([elementUpdated(instance), elementUpdated(second)]); + + expect(instance.callCount).to.be.greaterThan(0); + expect(second.callCount).to.be.greaterThan(0); + }); + + it('one controller unobserving does not prevent the other from receiving callbacks', async () => { + instance.resolver.observe(); + second.resolver.observe(); + instance.resolver.unobserve(); + + el = document.createElement('div'); + el.id = 'shared-one-unobserve'; + document.body.appendChild(el); + await Promise.all([elementUpdated(instance), elementUpdated(second)]); + + expect(instance.callCount).to.equal(0); + expect(second.callCount).to.be.greaterThan(0); + }); + + it('both controllers unobserving stops all callbacks', async () => { + instance.resolver.observe(); + second.resolver.observe(); + instance.resolver.unobserve(); + second.resolver.unobserve(); + + el = document.createElement('div'); + el.id = 'shared-both-unobserve'; + document.body.appendChild(el); + await Promise.all([elementUpdated(instance), elementUpdated(second)]); + + expect(instance.callCount).to.equal(0); + expect(second.callCount).to.equal(0); + }); + }); +}); diff --git a/src/components/common/controllers/id-resolver.ts b/src/components/common/controllers/id-resolver.ts new file mode 100644 index 0000000000..4ff65799a5 --- /dev/null +++ b/src/components/common/controllers/id-resolver.ts @@ -0,0 +1,158 @@ +import { isServer, type LitElement, type ReactiveController } from 'lit'; +import { getElementByIdFromRoot, isElement } from '../util.js'; + +function refObserverCallback( + mutations: MutationRecord[], + emitter: IdRefChangeEmitter +): void { + const affected = new Set(); + + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + const oldId = mutation.oldValue; + const newId = (mutation.target as Element).id; + if (oldId) affected.add(oldId); + if (newId) affected.add(newId); + } else { + for (const nodes of [mutation.addedNodes, mutation.removedNodes]) { + for (const node of nodes) { + if (!isElement(node)) continue; + if (node.id) affected.add(node.id); + if (node.childElementCount > 0) { + for (const child of node.querySelectorAll('[id]')) { + if (child.id) affected.add(child.id); + } + } + } + } + } + } + + if (affected.size > 0) { + emitter.dispatchEvent( + new CustomEvent('id-refs-change', { detail: affected }) + ); + } +} + +/** + * Emits events when ID references in the document change, allowing components to reactively update resolved references. + * Uses a reference counting mechanism to avoid unnecessary observation when no components are using it. + */ +class IdRefChangeEmitter extends EventTarget { + private readonly _observer?: MutationObserver; + private _refCount = 0; + + constructor() { + super(); + + if (!isServer) { + this._observer = new MutationObserver((mutations) => + refObserverCallback(mutations, this) + ); + } + } + + /** + * Start observing the document for ID reference changes. Multiple calls will be reference-counted to avoid redundant observers. + */ + public connect(): void { + if (this._refCount++ === 0) { + this._observer?.observe(document.body, { + attributeFilter: ['id'], + attributeOldValue: true, + subtree: true, + childList: true, + }); + } + } + + /** + * Stop observing the document for ID reference changes. Multiple calls will be reference-counted to avoid disconnecting if still in use. + */ + public disconnect(): void { + if (this._refCount > 0 && --this._refCount === 0) { + this._observer?.disconnect(); + } + } +} + +const ID_REF_CHANGE_EMITTER = new IdRefChangeEmitter(); + +/** + * Reactive controller that allows a host component to resolve ID references + * scoped to its root node, and react to changes in those references. + */ +class IdRefResolverController implements ReactiveController { + private readonly _host: LitElement; + private readonly _callback: (ids: Set) => unknown; + private _active = false; + private _connected = false; + + constructor(host: LitElement, callback: (ids: Set) => unknown) { + this._host = host; + this._host.addController(this); + this._callback = callback; + } + + /** @internal */ + public handleEvent(event: Event): void { + this._callback.call(this._host, (event as CustomEvent>).detail); + } + + /** @internal */ + public hostConnected(): void { + this._connected = true; + if (this._active) { + ID_REF_CHANGE_EMITTER.connect(); + ID_REF_CHANGE_EMITTER.addEventListener('id-refs-change', this); + } + } + + /** @internal */ + public hostDisconnected(): void { + if (this._active) { + ID_REF_CHANGE_EMITTER.removeEventListener('id-refs-change', this); + ID_REF_CHANGE_EMITTER.disconnect(); + } + this._connected = false; + } + + /** Start tracking ID reference changes in the document. */ + public observe(): void { + if (this._active) return; + this._active = true; + if (this._connected) { + ID_REF_CHANGE_EMITTER.connect(); + ID_REF_CHANGE_EMITTER.addEventListener('id-refs-change', this); + } + } + + /** Stop tracking ID reference changes in the document. */ + public unobserve(): void { + if (!this._active) return; + this._active = false; + if (this._connected) { + ID_REF_CHANGE_EMITTER.removeEventListener('id-refs-change', this); + ID_REF_CHANGE_EMITTER.disconnect(); + } + } + + /** Resolve an ID string to an element, scoped to the host's root node. */ + public resolve(id: string): Element | null { + return getElementByIdFromRoot(this._host, id); + } +} + +/** + * Adds an ID reference resolver controller to the host component, allowing it to resolve ID references scoped to + * its root node and react to changes in those references. + */ +export function addIdRefResolver( + host: LitElement, + callback: (ids: Set) => unknown +): IdRefResolverController { + return new IdRefResolverController(host, callback); +} + +export type { IdRefResolverController }; diff --git a/src/components/common/mixins/alert.ts b/src/components/common/mixins/alert.ts index 1675e4a65a..f009a99d06 100644 --- a/src/components/common/mixins/alert.ts +++ b/src/components/common/mixins/alert.ts @@ -3,6 +3,7 @@ import { property } from 'lit/decorators.js'; import { addAnimationController } from '../../../animations/player.js'; import { fadeIn, fadeOut } from '../../../animations/presets/fade/index.js'; import type { AbsolutePosition } from '../../types.js'; +import { addCommandController } from '../controllers/command.js'; import { addInternalsController } from '../controllers/internals.js'; import { getVisibleAncestor, isPopoverOpen } from '../util.js'; @@ -66,6 +67,11 @@ export abstract class IgcBaseAlertLikeComponent extends LitElement { constructor() { super(); + addCommandController(this) + .set('--show', this.show) + .set('--hide', this.hide) + .set('--toggle', this.toggle); + addInternalsController(this, { initialARIA: { role: 'status', 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 ` + + Home + + + `); + + invoker = container.querySelector('button')!; + navDrawer = + container.querySelector('igc-nav-drawer')!; + + invoker.setAttribute('command', '--show'); + invoker.setAttribute('commandfor', 'native-invoker-nav-drawer'); + }); + + it('`--show` opens the drawer', async () => { + expect(navDrawer.open).to.be.false; + + invoker.click(); + await waitUntil(() => navDrawer.open); + + expect(navDrawer.open).to.be.true; + }); + + it('`--hide` closes an open drawer', async () => { + await navDrawer.show(); + expect(navDrawer.open).to.be.true; + + invoker.setAttribute('command', '--hide'); + + invoker.click(); + await waitUntil(() => !navDrawer.open); + + expect(navDrawer.open).to.be.false; + }); + + it('`--toggle` opens a closed drawer', async () => { + expect(navDrawer.open).to.be.false; + + invoker.setAttribute('command', '--toggle'); + + invoker.click(); + await waitUntil(() => navDrawer.open); + + expect(navDrawer.open).to.be.true; + }); + + it('`--toggle` closes an open drawer', async () => { + await navDrawer.show(); + expect(navDrawer.open).to.be.true; + + invoker.setAttribute('command', '--toggle'); + + invoker.click(); + await waitUntil(() => !navDrawer.open); + + expect(navDrawer.open).to.be.false; + }); + + it('a disabled native button does not invoke commands', async () => { + invoker.disabled = true; + + invoker.click(); + await elementUpdated(navDrawer); + + expect(navDrawer.open).to.be.false; + }); + }); + }); + async function createNavDrawer(template?: TemplateResult) { return await fixture( template ?? diff --git a/src/components/nav-drawer/nav-drawer.ts b/src/components/nav-drawer/nav-drawer.ts index e9980a6614..57f82fe682 100644 --- a/src/components/nav-drawer/nav-drawer.ts +++ b/src/components/nav-drawer/nav-drawer.ts @@ -4,6 +4,7 @@ import { cache } from 'lit/directives/cache.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { addThemingController } from '../../theming/theming-controller.js'; +import { addCommandController } from '../common/controllers/command.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; @@ -30,13 +31,25 @@ export interface IgcNavDrawerComponentEventMap { * A side navigation container that provides * quick access between views within an application. * + * For non-relative positions (`start`, `end`, `top`, `bottom`) the drawer is + * rendered as a native `` element, providing modal semantics, automatic + * focus trapping, and a backdrop. For the `relative` position it is rendered + * inline as a `