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..f1631c3c53 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,89 @@ 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; + }); + }); + }); }); diff --git a/src/components/banner/banner.ts b/src/components/banner/banner.ts index db130d9640..5146e52e75 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,39 @@ 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. + * + * The component integrates with the + * [Invoker Commands API](https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API): + * an Ignite 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..0288523795 100644 --- a/src/components/button/button.spec.ts +++ b/src/components/button/button.spec.ts @@ -1,6 +1,6 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; - import { defineComponents } from '../common/definitions/defineComponents.js'; +import { isPopoverOpen } from '../common/util.js'; import { createFormAssociatedTestBed, isFocused, @@ -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(isPopoverOpen(popover)).to.be.false; + + button.click(); + expect(isPopoverOpen(popover)).to.be.true; + + button.click(); + expect(isPopoverOpen(popover)).to.be.false; + }); + + it('shows a closed native popover', async () => { + button.command = 'show-popover'; + await elementUpdated(button); + + expect(isPopoverOpen(popover)).to.be.false; + + button.click(); + expect(isPopoverOpen(popover)).to.be.true; + }); + + it('hides a visible native popover', async () => { + button.command = 'hide-popover'; + await elementUpdated(button); + + popover.showPopover(); + expect(isPopoverOpen(popover)).to.be.true; + + button.click(); + expect(isPopoverOpen(popover)).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..6ade386922 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. @@ -33,8 +37,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 +53,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..86493131d2 100644 --- a/src/components/button/icon-button.ts +++ b/src/components/button/icon-button.ts @@ -17,8 +17,19 @@ 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 the normal 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. */ @@ -33,29 +44,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 +81,7 @@ export default class IgcIconButtonComponent extends IgcButtonBaseComponent { addThemingController(this, all); } - protected renderContent() { + protected _renderContent() { return html` ${this.name || this.mirrored ? html` @@ -73,7 +89,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..76d687a55c --- /dev/null +++ b/src/components/common/controllers/id-resolver.spec.ts @@ -0,0 +1,555 @@ +import { + defineCE, + elementUpdated, + expect, + fixture, + html, + unsafeStatic, +} from '@open-wc/testing'; +import { LitElement } from 'lit'; +import { getElementByIdFromRoot } from '../util.js'; +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(getElementByIdFromRoot(instance, '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(getElementByIdFromRoot(instance, '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(getElementByIdFromRoot(instance, 'removed-target')).to.not.be.null; + + target.remove(); + expect(getElementByIdFromRoot(instance, '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); + }); + }); + + // ─── Group 7: Shadow DOM scoping ────────────────────────────────────────── + + describe('Shadow DOM scoping', () => { + let shadowTag: string; + let shadowHost: HTMLElement; + let shadowInstance: HostInstance; + + before(() => { + shadowTag = 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(() => { + shadowHost = document.createElement('div'); + shadowHost.attachShadow({ mode: 'open' }); + document.body.appendChild(shadowHost); + + shadowInstance = document.createElement(shadowTag) as HostInstance; + shadowHost.shadowRoot!.appendChild(shadowInstance); + }); + + afterEach(() => { + shadowInstance.resolver.unobserve(); + shadowHost.remove(); + }); + + it('detects ID additions within its own shadow root', async () => { + shadowInstance.resolver.observe(); + await elementUpdated(shadowInstance); + + const el = document.createElement('div'); + el.id = 'shadow-target'; + shadowHost.shadowRoot!.appendChild(el); + await elementUpdated(shadowInstance); + + expect(shadowInstance.callCount).to.be.greaterThan(0); + expect(shadowInstance.receivedIds!.has('shadow-target')).to.be.true; + }); + + it('detects ID removals within its own shadow root', async () => { + const el = document.createElement('div'); + el.id = 'shadow-remove'; + shadowHost.shadowRoot!.appendChild(el); + + shadowInstance.resolver.observe(); + await elementUpdated(shadowInstance); + + shadowInstance.callCount = 0; + el.remove(); + await elementUpdated(shadowInstance); + + expect(shadowInstance.callCount).to.be.greaterThan(0); + expect(shadowInstance.receivedIds!.has('shadow-remove')).to.be.true; + }); + + it('detects ID attribute changes within its own shadow root', async () => { + const el = document.createElement('div'); + el.id = 'shadow-old'; + shadowHost.shadowRoot!.appendChild(el); + + shadowInstance.resolver.observe(); + await elementUpdated(shadowInstance); + + shadowInstance.callCount = 0; + el.id = 'shadow-new'; + await elementUpdated(shadowInstance); + + expect(shadowInstance.callCount).to.be.greaterThan(0); + expect(shadowInstance.receivedIds!.has('shadow-old')).to.be.true; + expect(shadowInstance.receivedIds!.has('shadow-new')).to.be.true; + }); + + it('does NOT react to ID changes in the document when scoped to a shadow root', async () => { + shadowInstance.resolver.observe(); + await elementUpdated(shadowInstance); + + const el = document.createElement('div'); + el.id = 'document-only'; + document.body.appendChild(el); + await elementUpdated(shadowInstance); + + expect(shadowInstance.callCount).to.equal(0); + el.remove(); + }); + + it('does NOT react to ID changes in a different shadow root', async () => { + shadowInstance.resolver.observe(); + await elementUpdated(shadowInstance); + + const otherHost = document.createElement('div'); + otherHost.attachShadow({ mode: 'open' }); + document.body.appendChild(otherHost); + + const el = document.createElement('div'); + el.id = 'other-shadow'; + otherHost.shadowRoot!.appendChild(el); + await elementUpdated(shadowInstance); + + expect(shadowInstance.callCount).to.equal(0); + otherHost.remove(); + }); + + it('resolves elements scoped to its own shadow root', () => { + const el = document.createElement('div'); + el.id = 'resolve-shadow'; + shadowHost.shadowRoot!.appendChild(el); + + expect(getElementByIdFromRoot(shadowInstance, 'resolve-shadow')).to.equal( + el + ); + }); + + it('does NOT resolve elements from the document root', () => { + const el = document.createElement('div'); + el.id = 'doc-element'; + document.body.appendChild(el); + + expect(getElementByIdFromRoot(shadowInstance, 'doc-element')).to.be.null; + el.remove(); + }); + + it('document-scoped controller does NOT react to shadow root changes', async () => { + instance.resolver.observe(); + + const el = document.createElement('div'); + el.id = 'inside-shadow-only'; + shadowHost.shadowRoot!.appendChild(el); + await elementUpdated(instance); + + expect(instance.callCount).to.equal(0); + instance.resolver.unobserve(); + }); + }); +}); diff --git a/src/components/common/controllers/id-resolver.ts b/src/components/common/controllers/id-resolver.ts new file mode 100644 index 0000000000..12356b6bcc --- /dev/null +++ b/src/components/common/controllers/id-resolver.ts @@ -0,0 +1,180 @@ +import { isServer, type LitElement, type ReactiveController } from 'lit'; +import { isDocument, isElement } from '../util.js'; + +const ID_REF_EMITTERS = new WeakMap(); +const ID_REF_EVENT = 'id-refs-change'; + +function getEmitter(root: Node): IdRefChangeEmitter { + let emitter = ID_REF_EMITTERS.get(root); + if (!emitter) { + emitter = new IdRefChangeEmitter(root); + ID_REF_EMITTERS.set(root, emitter); + } + return emitter; +} + +function refObserverCallback( + mutations: MutationRecord[], + emitter: IdRefChangeEmitter +): void { + const affected = new Set(); + + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + if (!isElement(mutation.target)) continue; + const oldId = mutation.oldValue; + const newId = mutation.target.id; + if (oldId) affected.add(oldId); + if (newId) affected.add(newId); + } else { + for (const node of mutation.addedNodes) { + if (!isElement(node)) continue; + if (node.id) affected.add(node.id); + for (const child of node.querySelectorAll('[id]')) { + if (child.id) affected.add(child.id); + } + } + for (const node of mutation.removedNodes) { + if (!isElement(node)) continue; + if (node.id) affected.add(node.id); + for (const child of node.querySelectorAll('[id]')) { + if (child.id) affected.add(child.id); + } + } + } + } + + if (affected.size > 0) { + emitter.dispatchEvent(new CustomEvent(ID_REF_EVENT, { detail: affected })); + } +} + +/** + * Emits events when ID references in a root node 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 readonly _root: Node; + private _refCount = 0; + + constructor(root: Node) { + super(); + this._root = root; + + if (!isServer) { + this._observer = new MutationObserver((mutations) => + refObserverCallback(mutations, this) + ); + } + } + + /** + * Increment the reference count. Starts the underlying MutationObserver on the first call. + */ + public retain(): void { + if (this._refCount++ === 0) { + const root = isDocument(this._root) ? this._root.body : this._root; + this._observer?.observe(root, { + attributeFilter: ['id'], + attributeOldValue: true, + subtree: true, + childList: true, + }); + } + } + + /** + * Decrement the reference count. Stops the underlying MutationObserver when the count reaches zero. + */ + public release(): void { + if (this._refCount > 0 && --this._refCount === 0) { + this._observer?.disconnect(); + } + } +} + +/** + * 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; + private _emitter: IdRefChangeEmitter | null = null; + + constructor(host: LitElement, callback: (ids: Set) => unknown) { + this._host = host; + this._host.addController(this); + this._callback = callback; + } + + private _observe(): void { + const root = this._host.getRootNode(); + this._emitter = getEmitter(root); + this._emitter.retain(); + this._emitter.addEventListener(ID_REF_EVENT, this); + } + + private _unobserve(): void { + if (this._emitter) { + this._emitter.removeEventListener(ID_REF_EVENT, this); + this._emitter.release(); + this._emitter = null; + } + } + + /** @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) { + this._observe(); + } + } + + /** @internal */ + public hostDisconnected(): void { + if (this._active) { + this._unobserve(); + } + this._connected = false; + } + + /** Start tracking ID reference changes in the document. */ + public observe(): void { + if (this._active) return; + this._active = true; + if (this._connected) { + this._observe(); + } + } + + /** Stop tracking ID reference changes in the document. */ + public unobserve(): void { + if (!this._active) return; + this._active = false; + if (this._connected) { + this._unobserve(); + } + } +} + +/** + * 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/common/util.ts b/src/components/common/util.ts index e89edb25df..984f4d6563 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -145,6 +145,10 @@ export function isElement(node: unknown): node is Element { return node instanceof Node && node.nodeType === Node.ELEMENT_NODE; } +export function isDocument(node: unknown): node is Document { + return node instanceof Node && node.nodeType === Node.DOCUMENT_NODE; +} + /** * Finds the first element in the event's composed path that matches the provided predicate, which can be either a string selector or a function. * diff --git a/src/components/dialog/dialog.spec.ts b/src/components/dialog/dialog.spec.ts index 6403ccbc1b..44ef87ad6e 100644 --- a/src/components/dialog/dialog.spec.ts +++ b/src/components/dialog/dialog.spec.ts @@ -279,6 +279,89 @@ 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('Form', () => { beforeEach(async () => { dialog = await fixture(html` diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index d636c51e55..7d5dcca21b 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,41 @@ 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 Ignite button or a native `