diff --git a/CHANGELOG.md b/CHANGELOG.md index 178ce9d76..300c1e61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- #### Nav Drawer + - The drawer for non-relative positions are now implemented using the native Popover API, providing built-in modal semantics, focus trapping, and accessibility support. + - 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. + +### Changed +- #### Nav Drawer + - The `overlay` CSS part has been removed. The native `` element's `::backdrop` pseudo-element should be used for backdrop styling instead. - #### Form controls - `IgcInput`, `IgcMaskInput`, `IgcDateTimeInput`, `IgcCheckbox`, `IgcRadio`, `IgcSwitch`, `IgcDatePicker`, and `IgcDateRangePicker` now submit their associated form on **Enter** key press, matching native browser behavior. diff --git a/src/components/nav-drawer/nav-drawer.spec.ts b/src/components/nav-drawer/nav-drawer.spec.ts index 9247367df..cefccab13 100644 --- a/src/components/nav-drawer/nav-drawer.spec.ts +++ b/src/components/nav-drawer/nav-drawer.spec.ts @@ -1,7 +1,14 @@ -import { expect, fixture, html } from '@open-wc/testing'; +import { + elementUpdated, + expect, + fixture, + html, + waitUntil, +} from '@open-wc/testing'; import type { TemplateResult } from 'lit'; - +import { spy } from 'sinon'; import { defineComponents } from '../common/definitions/defineComponents.js'; +import { simulateClick } from '../common/utils.spec.js'; import IgcIconComponent from '../icon/icon.js'; import IgcNavDrawerComponent from './nav-drawer.js'; @@ -12,12 +19,6 @@ describe('Navigation Drawer', () => { let navDrawer: IgcNavDrawerComponent; - // Workaround since transitionend is not emitted in the tests - async function runWithTransition(awaitable: Promise) { - navDrawer.renderRoot.dispatchEvent(new Event('transitionend')); - return await awaitable; - } - describe('Accessibility', () => { beforeEach(async () => { navDrawer = await createNavDrawer(); @@ -29,7 +30,8 @@ describe('Navigation Drawer', () => { }); it('passes the a11y audit (open state)', async () => { - await runWithTransition(navDrawer.show()); + await navDrawer.show(); + await elementUpdated(navDrawer); expect(navDrawer.open).to.be.true; await expect(navDrawer).dom.to.be.accessible(); @@ -63,16 +65,34 @@ describe('Navigation Drawer', () => { expect(navDrawer).to.contain('igc-nav-drawer-item'); }); - it('render navigation drawer slots', async () => { + it('renders dialog-based shadow DOM for non-relative position', async () => { navDrawer = await createNavDrawer(); expect(navDrawer).shadowDom.equal(` -
-
+
+
+
+
+ `); + }); + + it('renders nav-based shadow DOM for relative position', async () => { + navDrawer = await createNavDrawer(html` + + + + `); + + expect(navDrawer).shadowDom.equal(` +
@@ -123,25 +143,270 @@ describe('Navigation Drawer', () => { }); it('`show`', async () => { - await runWithTransition(navDrawer.show()); + expect(await navDrawer.show()).to.be.true; expect(navDrawer.open).to.be.true; - expect(await runWithTransition(navDrawer.show())).to.be.false; + expect(await navDrawer.show()).to.be.false; }); it('`hide`', async () => { - await runWithTransition(navDrawer.toggle()); - await runWithTransition(navDrawer.hide()); + await navDrawer.toggle(); + expect(await navDrawer.hide()).to.be.true; expect(navDrawer.open).to.be.false; - expect(await runWithTransition(navDrawer.hide())).to.be.false; + expect(await navDrawer.hide()).to.be.false; }); it('`toggle`', async () => { - await runWithTransition(navDrawer.toggle()); + expect(await navDrawer.toggle()).to.be.true; + expect(navDrawer.open).to.be.true; + + expect(await navDrawer.toggle()).to.be.true; + expect(navDrawer.open).to.be.false; + }); + }); + + describe('Events & Behaviors', () => { + let nativeDialog: HTMLDialogElement; + + beforeEach(async () => { + navDrawer = await createNavDrawer(); + nativeDialog = navDrawer.renderRoot.querySelector('dialog')!; + }); + + it('should correctly render with initial open state', async () => { + navDrawer = await createNavDrawer(html` + + + + `); + nativeDialog = navDrawer.renderRoot.querySelector('dialog')!; + + expect(navDrawer.open).to.be.true; + expect(nativeDialog.open).to.be.true; + }); + + it('should open dialog when position changes from relative to non-relative while open', async () => { + navDrawer = await createNavDrawer(html` + + + + `); + expect(navDrawer.open).to.be.true; + expect(navDrawer.renderRoot.querySelector('dialog')).to.be.null; + + navDrawer.position = 'start'; + await elementUpdated(navDrawer); + + nativeDialog = navDrawer.renderRoot.querySelector('dialog')!; + expect(nativeDialog).to.exist; + expect(nativeDialog.open).to.be.true; + }); - await runWithTransition(navDrawer.toggle()); + it('should close the native dialog when position changes to relative while open', async () => { + await navDrawer.show(); + await elementUpdated(navDrawer); + + expect(nativeDialog.open).to.be.true; + + navDrawer.position = 'relative'; + await elementUpdated(navDrawer); + + expect(navDrawer.open).to.be.true; + expect(navDrawer.renderRoot.querySelector('dialog')).to.be.null; + }); + + it('should close when the user presses Escape', async () => { + const eventSpy = spy(navDrawer, 'emitEvent'); + await navDrawer.show(); + await elementUpdated(navDrawer); + + nativeDialog.dispatchEvent(new Event('cancel')); + await elementUpdated(navDrawer); + await waitUntil(() => !navDrawer.open); + + expect(eventSpy.getCalls()).lengthOf(2); + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.secondCall).calledWith('igcClosed'); + }); + + it('should not close when Escape is pressed and `keepOpenOnEscape` is set', async () => { + const eventSpy = spy(navDrawer, 'emitEvent'); + + navDrawer.keepOpenOnEscape = true; + await navDrawer.show(); + await elementUpdated(navDrawer); + + nativeDialog.dispatchEvent(new Event('cancel')); + await elementUpdated(navDrawer); + + expect(navDrawer.open).to.be.true; + expect(eventSpy.getCalls()).is.empty; + }); + + it('should close when clicking outside in non-relative position', async () => { + await navDrawer.show(); + await elementUpdated(navDrawer); + + const eventSpy = spy(navDrawer, 'emitEvent'); + const { x, y } = nativeDialog.getBoundingClientRect(); + simulateClick(nativeDialog, { clientX: x + 1, clientY: y - 1 }); + await elementUpdated(navDrawer); + + await waitUntil(() => eventSpy.calledWith('igcClosed')); expect(navDrawer.open).to.be.false; }); + + it('should not close when clicking inside the dialog', async () => { + await navDrawer.show(); + await elementUpdated(navDrawer); + + const eventSpy = spy(navDrawer, 'emitEvent'); + const { x, y } = nativeDialog.getBoundingClientRect(); + + simulateClick(nativeDialog, { clientX: x + 1, clientY: y + 1 }); + await elementUpdated(navDrawer); + + expect(eventSpy).not.calledWith('igcClosed'); + expect(navDrawer.open).to.be.true; + }); + + it('should not close when clicking outside in relative position', async () => { + const relativeNavDrawer = await createNavDrawer(html` + + + + `); + + await relativeNavDrawer.show(); + await elementUpdated(relativeNavDrawer); + + const eventSpy = spy(relativeNavDrawer, 'emitEvent'); + const { x, y } = relativeNavDrawer.getBoundingClientRect(); + simulateClick(relativeNavDrawer, { clientX: x + 1, clientY: y - 1 }); + await elementUpdated(relativeNavDrawer); + + expect(eventSpy.calledWith('igcClosed')).to.be.false; + expect(relativeNavDrawer.open).to.be.true; + }); + + it('can cancel `igcClosing` event', async () => { + await navDrawer.show(); + await elementUpdated(navDrawer); + + const eventSpy = spy(navDrawer, 'emitEvent'); + navDrawer.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, + }); + + nativeDialog.dispatchEvent(new Event('cancel')); + await elementUpdated(navDrawer); + + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).not.calledWith('igcClosed'); + expect(navDrawer.open).to.be.true; + }); + + it('does not close when keepOpenOnEscape is true and a non-cancelable close event is fired', async () => { + navDrawer.keepOpenOnEscape = true; + await navDrawer.show(); + await elementUpdated(navDrawer); + + nativeDialog.dispatchEvent(new Event('close')); + await elementUpdated(navDrawer); + + expect(navDrawer.open).to.be.true; + }); + + it('programmatic hide does not emit events', async () => { + const eventSpy = spy(navDrawer, 'emitEvent'); + await navDrawer.show(); + await navDrawer.hide(); + + expect(eventSpy.getCalls()).is.empty; + }); + }); + + describe('Mini slot popover', () => { + function getMiniElement(drawer: IgcNavDrawerComponent): HTMLDivElement { + return drawer.renderRoot.querySelector('[part~="mini"]')!; + } + + it('is shown when the drawer is initially closed with mini content', async () => { + navDrawer = await createNavDrawerWithMini(); + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true; + }); + + it('is hidden when the drawer opens', async () => { + navDrawer = await createNavDrawerWithMini(); + await navDrawer.show(); + await elementUpdated(navDrawer); + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false; + }); + + it('is shown again when the drawer closes', async () => { + navDrawer = await createNavDrawerWithMini(); + await navDrawer.show(); + await elementUpdated(navDrawer); + await navDrawer.hide(); + await elementUpdated(navDrawer); + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true; + }); + + it('is not shown when there is no mini slot content', async () => { + navDrawer = await createNavDrawer(); + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false; + }); + + it('is hidden when position changes to relative', async () => { + navDrawer = await createNavDrawerWithMini(); + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true; + + navDrawer.position = 'relative'; + await elementUpdated(navDrawer); + + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false; + }); + + it('is shown when position changes from relative to non-relative while closed', async () => { + navDrawer = await fixture(html` + + + + `); + + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false; + + navDrawer.position = 'start'; + await elementUpdated(navDrawer); + + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true; + }); + + it('is shown when mini content is dynamically added', async () => { + navDrawer = await createNavDrawer(); + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.false; + + const item = document.createElement('igc-nav-drawer-item'); + item.slot = 'mini'; + navDrawer.appendChild(item); + + await waitUntil( + () => getMiniElement(navDrawer).matches(':popover-open'), + 'Expected mini popover to be shown after adding mini content' + ); + }); + + it('is hidden when all mini content is removed', async () => { + navDrawer = await createNavDrawerWithMini(); + expect(getMiniElement(navDrawer).matches(':popover-open')).to.be.true; + + navDrawer.querySelector('[slot="mini"]')!.remove(); + + await waitUntil( + () => !getMiniElement(navDrawer).matches(':popover-open'), + 'Expected mini popover to be hidden after removing mini content' + ); + }); }); async function createNavDrawer(template?: TemplateResult) { @@ -154,4 +419,12 @@ describe('Navigation Drawer', () => { ` ); } + + async function createNavDrawerWithMini() { + return await fixture(html` + + + + `); + } }); diff --git a/src/components/nav-drawer/nav-drawer.ts b/src/components/nav-drawer/nav-drawer.ts index 676d30ddf..8766125f5 100644 --- a/src/components/nav-drawer/nav-drawer.ts +++ b/src/components/nav-drawer/nav-drawer.ts @@ -1,9 +1,15 @@ -import { html, LitElement } from 'lit'; +import { html, LitElement, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; +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 { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { partMap } from '../common/part-map.js'; +import { bindIf, numberInRangeInclusive } from '../common/util.js'; import type { NavDrawerPosition } from '../types.js'; import IgcNavDrawerHeaderItemComponent from './nav-drawer-header-item.js'; import IgcNavDrawerItemComponent from './nav-drawer-item.js'; @@ -11,22 +17,37 @@ import { styles } from './themes/container.base.css.js'; import { all } from './themes/container.js'; import { styles as shared } from './themes/shared/container/nav-drawer.common.css.js'; +export interface IgcNavDrawerComponentEventMap { + igcClosing: CustomEvent; + igcClosed: CustomEvent; +} + /** - * Represents a side navigation container that provides - * quick access between views. + * A side navigation container that provides + * quick access between views within an application. + * + * + * When content is provided in the `mini` slot, a compact icon-only variant is + * displayed alongside the main drawer. * * @element igc-nav-drawer * - * @slot - The default slot for the igc-navigation-drawer. - * @slot mini - The slot for the mini variant of the igc-navigation-drawer. + * @fires igcClosing - Emitted just before the drawer is closed by a user interaction. Cancelable. + * @fires igcClosed - Emitted just after the drawer is closed by a user interaction. + * + * @slot - Renders the main navigation content of the drawer. + * @slot mini - Renders the compact mini variant of the drawer. * - * @csspart base - The base wrapper of the igc-navigation-drawer. - * @csspart main - The main container of the igc-navigation-drawer. - * @csspart mini - The mini container of the igc-navigation-drawer. + * @csspart base - The base wrapper of the drawer. + * @csspart main - The main content container of the drawer. + * @csspart mini - The mini variant container of the drawer. */ -export default class IgcNavDrawerComponent extends LitElement { +export default class IgcNavDrawerComponent extends EventEmitterMixin< + IgcNavDrawerComponentEventMap, + Constructor +>(LitElement) { public static readonly tagName = 'igc-nav-drawer'; - public static override styles = [styles, shared]; + public static styles = [styles, shared]; /* blazorSuppress */ public static register(): void { @@ -37,86 +58,283 @@ export default class IgcNavDrawerComponent extends LitElement { ); } + //#region Internal state + + private readonly _dialogRef = createRef(); + private readonly _miniRef = createRef(); + private readonly _slots = addSlotController(this, { slots: setSlots('mini'), + onChange: this._handleMiniState, }); + private get _dialog(): HTMLDialogElement | undefined { + return this._dialogRef.value; + } + + private get _mini(): HTMLDivElement | undefined { + return this._miniRef.value; + } + + private get _hasMiniContent(): boolean { + return this._slots.hasAssignedElements('mini'); + } + + private get _isRelative(): boolean { + return this.position === 'relative'; + } + + //#endregion + + //#region Public properties + /** - * The position of the drawer. - * @attr + * Sets the position of the drawer. + * + * - `start` — anchored to the inline-start edge (default). + * - `end` — anchored to the inline-end edge. + * - `top` — anchored to the block-start edge. + * - `bottom` — anchored to the block-end edge. + * - `relative` — rendered inline within the page flow; no modal backdrop. + * + * @attr position + * @default 'start' */ @property({ reflect: true }) public position: NavDrawerPosition = 'start'; /** - * Determines whether the drawer is opened. - * @attr + * Whether the drawer is open. + * + * @attr open + * @default false */ @property({ type: Boolean, reflect: true }) public open = false; + /** + * Determines whether the drawer should remain open when the Escape key is pressed. + * + * This attribute is only applicable when the drawer is in a non-relative position, + * as the Escape key does not trigger the closing of relative drawers. + * + * @attr keep-open-on-escape + * @default false + */ + @property({ type: Boolean, attribute: 'keep-open-on-escape' }) + public keepOpenOnEscape = false; + + /** + * Sets an accessible label for the drawer. + * + * In non-relative positions this label is applied to the modal `` element. + * In `relative` position it labels the `