From 8bd1126003d6eef78e10c8e18775828c486b75fd Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 20 Apr 2026 13:33:58 +0300 Subject: [PATCH 1/8] refactor(nav-drawer): Use native dialog for non-relative nav-drawers Closes #2122, #2186, #2187 --- src/components/nav-drawer/nav-drawer.spec.ts | 170 +++++++++++++++-- src/components/nav-drawer/nav-drawer.ts | 188 ++++++++++++++++--- src/index.ts | 1 + stories/nav-drawer.stories.ts | 27 ++- 4 files changed, 342 insertions(+), 44 deletions(-) diff --git a/src/components/nav-drawer/nav-drawer.spec.ts b/src/components/nav-drawer/nav-drawer.spec.ts index 9247367df8..375381dbeb 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,11 +65,29 @@ 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 div-based shadow DOM for relative position', async () => { + navDrawer = await createNavDrawer(html` + + + + `); + + expect(navDrawer).shadowDom.equal(`
@@ -123,25 +143,139 @@ 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 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); - await runWithTransition(navDrawer.toggle()); + 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 () => { + navDrawer.position = 'relative'; + await navDrawer.show(); + await elementUpdated(navDrawer); + + const eventSpy = spy(navDrawer, 'emitEvent'); + const { x, y } = navDrawer.getBoundingClientRect(); + simulateClick(nativeDialog, { clientX: x + 1, clientY: y - 1 }); + await elementUpdated(navDrawer); + + expect(eventSpy.calledWith('igcClosed')).to.be.false; + expect(navDrawer.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; + }); }); async function createNavDrawer(template?: TemplateResult) { diff --git a/src/components/nav-drawer/nav-drawer.ts b/src/components/nav-drawer/nav-drawer.ts index 676d30ddf1..ad673a26e0 100644 --- a/src/components/nav-drawer/nav-drawer.ts +++ b/src/components/nav-drawer/nav-drawer.ts @@ -1,9 +1,14 @@ -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 { 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,12 +16,20 @@ 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. * * @element igc-nav-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 - The default slot for the igc-navigation-drawer. * @slot mini - The slot for the mini variant of the igc-navigation-drawer. * @@ -24,9 +37,12 @@ import { styles as shared } from './themes/shared/container/nav-drawer.common.cs * @csspart main - The main container of the igc-navigation-drawer. * @csspart mini - The mini container of the igc-navigation-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,37 +53,135 @@ export default class IgcNavDrawerComponent extends LitElement { ); } + //#region Internal state + + private readonly _dialogRef = createRef(); + private readonly _slots = addSlotController(this, { slots: setSlots('mini'), }); + private get _dialog(): HTMLDialogElement | undefined { + return this._dialogRef.value; + } + + private get _isRelative(): boolean { + return this.position === 'relative'; + } + + //#endregion + + //#region Public properties + /** * The position of the drawer. - * @attr + * + * @attr position + * @default 'start' */ @property({ reflect: true }) public position: NavDrawerPosition = 'start'; /** * Determines whether the drawer is opened. - * @attr + * + * @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; + + //#endregion + + //#region Lifecycle + constructor() { super(); addThemingController(this, all); } - private _waitTransitions() { - return new Promise((resolve) => { - this.renderRoot.addEventListener('transitionend', resolve, { - once: true, - }); - }); + protected override update(properties: PropertyValues): void { + if (properties.has('open')) { + this._handleOpenState(); + } + + super.update(properties); + } + + //#endregion + + //#region Event handlers + + private _handleOpenState(): void { + if (this._isRelative) { + return; + } + + this.open ? this._dialog?.showModal() : this._dialog?.close(); + } + + private _handleCancel(event: Event): void { + event.preventDefault(); + + if (!this.keepOpenOnEscape) { + this._closeWithEvent(); + } + } + + private _handleClose(): void { + if (this.open) { + this._dialog?.showModal(); + } + } + + private _handleClick({ clientX, clientY, target }: PointerEvent): void { + if (this._dialog === target) { + const rect = this._dialog.getBoundingClientRect(); + const inX = numberInRangeInclusive(clientX, rect.left, rect.right); + const inY = numberInRangeInclusive(clientY, rect.top, rect.bottom); + + if (!(inX && inY)) { + this._closeWithEvent(); + } + } + } + + //#endregion + + //#region Internal API + + private _emitClosing(): boolean { + return this.emitEvent('igcClosing', { cancelable: true }); + } + + private async _closeWithEvent(): Promise { + if (!(this.open && this._emitClosing())) { + return false; + } + + this.open = false; + await this.updateComplete; + + this.emitEvent('igcClosed'); + return true; } + //#endregion + + //#region Public API + /** Opens the drawer. */ public async show(): Promise { if (this.open) { @@ -75,7 +189,7 @@ export default class IgcNavDrawerComponent extends LitElement { } this.open = true; - await this._waitTransitions(); + await this.updateComplete; return true; } @@ -87,7 +201,7 @@ export default class IgcNavDrawerComponent extends LitElement { } this.open = false; - await this._waitTransitions(); + await this.updateComplete; return true; } @@ -97,16 +211,10 @@ export default class IgcNavDrawerComponent extends LitElement { return this.open ? this.hide() : this.show(); } - protected override render() { - return html` -
- -
-
- -
-
+ //#endregion + private _renderMiniVariant() { + return html`
`; } + + private _renderContent() { + return html` +
+ +
+ `; + } + + private _renderDialog() { + return html` + + ${this._renderContent()} + + ${this._renderMiniVariant()} + `; + } + + private _renderRelative() { + return html` +
${this._renderContent()}
+ ${this._renderMiniVariant()} + `; + } + + protected override render() { + return html`${cache( + this._isRelative ? this._renderRelative() : this._renderDialog() + )}`; + } } declare global { diff --git a/src/index.ts b/src/index.ts index c1f626e042..2a9f4e9892 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,6 +140,7 @@ export type { IgcDropdownComponentEventMap } from './components/dropdown/dropdow export type { IgcExpansionPanelComponentEventMap } from './components/expansion-panel/expansion-panel.js'; export type { IgcInputComponentEventMap } from './components/input/input-base.js'; export type { IgcInputComponentEventMap as IgcMaskInputComponentEventMap } from './components/input/input-base.js'; +export type { IgcNavDrawerComponentEventMap } from './components/nav-drawer/nav-drawer.js'; export type { IgcFileInputComponentEventMap } from './components/file-input/file-input.js'; export type { IgcRadioComponentEventMap } from './components/radio/radio.js'; export type { IgcRatingComponentEventMap } from './components/rating/rating.js'; diff --git a/stories/nav-drawer.stories.ts b/stories/nav-drawer.stories.ts index 161df0ad9a..8f6765bd25 100644 --- a/stories/nav-drawer.stories.ts +++ b/stories/nav-drawer.stories.ts @@ -24,6 +24,7 @@ const metadata: Meta = { 'Represents a side navigation container that provides\nquick access between views.', }, }, + actions: { handles: ['igcClosing', 'igcClosed'] }, }, argTypes: { position: { @@ -39,8 +40,15 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + keepOpenOnEscape: { + type: 'boolean', + description: + 'Determines whether the drawer should remain open when the Escape key is pressed.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, }, - args: { position: 'start', open: false }, + args: { position: 'start', open: false, keepOpenOnEscape: false }, }; export default metadata; @@ -50,6 +58,8 @@ interface IgcNavDrawerArgs { position: 'start' | 'end' | 'top' | 'bottom' | 'relative'; /** Determines whether the drawer is opened. */ open: boolean; + /** Determines whether the drawer should remain open when the Escape key is pressed. */ + keepOpenOnEscape: boolean; } type Story = StoryObj; @@ -133,7 +143,7 @@ const createDrawerContent = (headerText: string, itemCount = 15) => html` (i) => html` - Navbar item ${i + 1} + Navar item ${i + 1} ` )} @@ -175,11 +185,20 @@ const createTemplate = (options: { includeMini?: boolean; contentText?: string; }) => { - return ({ open = false, position }: IgcNavDrawerArgs) => html` + return ({ + open = false, + position, + keepOpenOnEscape = false, + }: IgcNavDrawerArgs) => html` ${commonStyles}
- + ${createDrawerContent( options.headerText || 'Sample Drawer', options.itemCount From 14fb154524b8786ce4582b67b5f0882bdd3c379a Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 20 Apr 2026 15:59:37 +0300 Subject: [PATCH 2/8] fix: Addressed PR review comments - Render correctly on first update with initial open state - Correctly sync native dialog open state when changing drawer positioning --- src/components/nav-drawer/nav-drawer.spec.ts | 64 +++++++++++++++++--- src/components/nav-drawer/nav-drawer.ts | 10 ++- stories/nav-drawer.stories.ts | 11 +++- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/components/nav-drawer/nav-drawer.spec.ts b/src/components/nav-drawer/nav-drawer.spec.ts index 375381dbeb..fbe93d48bc 100644 --- a/src/components/nav-drawer/nav-drawer.spec.ts +++ b/src/components/nav-drawer/nav-drawer.spec.ts @@ -172,6 +172,49 @@ describe('Navigation Drawer', () => { 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; + }); + + 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(); @@ -228,17 +271,22 @@ describe('Navigation Drawer', () => { }); it('should not close when clicking outside in relative position', async () => { - navDrawer.position = 'relative'; - await navDrawer.show(); - await elementUpdated(navDrawer); + const relativeNavDrawer = await createNavDrawer(html` + + + + `); - const eventSpy = spy(navDrawer, 'emitEvent'); - const { x, y } = navDrawer.getBoundingClientRect(); - simulateClick(nativeDialog, { clientX: x + 1, clientY: y - 1 }); - await elementUpdated(navDrawer); + 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(navDrawer.open).to.be.true; + expect(relativeNavDrawer.open).to.be.true; }); it('can cancel `igcClosing` event', async () => { diff --git a/src/components/nav-drawer/nav-drawer.ts b/src/components/nav-drawer/nav-drawer.ts index ad673a26e0..9c82ac0137 100644 --- a/src/components/nav-drawer/nav-drawer.ts +++ b/src/components/nav-drawer/nav-drawer.ts @@ -113,13 +113,19 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< } protected override update(properties: PropertyValues): void { - if (properties.has('open')) { - this._handleOpenState(); + if (properties.has('position') && this._isRelative) { + this._dialog?.close(); } super.update(properties); } + protected override updated(properties: PropertyValues): void { + if (properties.has('open') || properties.has('position')) { + this._handleOpenState(); + } + } + //#endregion //#region Event handlers diff --git a/stories/nav-drawer.stories.ts b/stories/nav-drawer.stories.ts index 8f6765bd25..b3433eba10 100644 --- a/stories/nav-drawer.stories.ts +++ b/stories/nav-drawer.stories.ts @@ -43,7 +43,7 @@ const metadata: Meta = { keepOpenOnEscape: { type: 'boolean', description: - 'Determines whether the drawer should remain open when the Escape key is pressed.', + 'Determines whether the drawer should remain open when the Escape key is pressed.\n\nThis attribute is only applicable when the drawer is in a non-relative position,\nas the Escape key does not trigger the closing of relative drawers.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -58,7 +58,12 @@ interface IgcNavDrawerArgs { position: 'start' | 'end' | 'top' | 'bottom' | 'relative'; /** Determines whether the drawer is opened. */ open: boolean; - /** Determines whether the drawer should remain open when the Escape key is pressed. */ + /** + * 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. + */ keepOpenOnEscape: boolean; } type Story = StoryObj; @@ -143,7 +148,7 @@ const createDrawerContent = (headerText: string, itemCount = 15) => html` (i) => html` - Navar item ${i + 1} + Drawer item ${i + 1} ` )} From ac8ef968ce8e8248c079c3f7c995a00a9bdb9a37 Mon Sep 17 00:00:00 2001 From: Simeon Simeonoff Date: Wed, 13 May 2026 20:36:21 +0300 Subject: [PATCH 3/8] refactor(nav-drawer): implement theme changes --- .../nav-drawer/themes/container.base.scss | 144 +++++++++++------- .../shared/container/nav-drawer.common.scss | 8 +- .../shared/container/nav-drawer.indigo.scss | 2 +- 3 files changed, 93 insertions(+), 61 deletions(-) diff --git a/src/components/nav-drawer/themes/container.base.scss b/src/components/nav-drawer/themes/container.base.scss index 3239946dc1..a10b1ca34f 100644 --- a/src/components/nav-drawer/themes/container.base.scss +++ b/src/components/nav-drawer/themes/container.base.scss @@ -3,8 +3,12 @@ :host { --menu-full-width: #{rem(240px)}; + --menu-height: 50vh; + --transition-function: cubic-bezier(0.445, 0.05, 0.55, 0.95); + --transition-duration: 0.35s; - display: flex; + display: grid; + grid-template-columns: repeat(2, max-content); flex-shrink: 0; font-family: var(--ig-font-family); @@ -12,18 +16,9 @@ [part~='mini'] { --ig-scrollbar-size: rem(8px); - position: fixed; height: 100%; min-height: 100%; overflow-x: hidden; - inset-block: 0; - z-index: 999; - opacity: 1; - transition: - translate ease-out 0.3s, - 0.3s, - opacity ease-out 0.3s, - 0.3s; } [part~='mini'] { @@ -32,20 +27,37 @@ [part='base'] { width: var(--menu-full-width); + margin: unset; + border-color: transparent; + padding: unset; + outline-style: none; + transition: + translate var(--transition-function) var(--transition-duration), + opacity var(--transition-function) var(--transition-duration), + overlay var(--transition-function) var(--transition-duration) + allow-discrete, + display var(--transition-function) var(--transition-duration) + allow-discrete; + + &::backdrop { + transition: opacity var(--transition-function) + var(--transition-duration); + + @starting-style { + opacity: 0; + } + } } [part='main'] { width: 100%; } +} - [part='overlay'] { - position: fixed; - inset: 0; - z-index: 2; - opacity: 1; - transition: - opacity ease-out 0.3s, - 0.3s; +:host(:not([open])[position='relative']) { + [part='base'] { + margin-inline-start: calc(var(--menu-full-width) * -1); + overflow: hidden; } } @@ -57,6 +69,7 @@ z-index: initial; border-inline-end-style: solid; border-inline-end-width: rem(1px); + transition: margin var(--transition-function) var(--transition-duration); } [part~='mini'] { @@ -64,10 +77,6 @@ border-inline-end-style: solid; border-inline-end-width: rem(1px); } - - [part='overlay'] { - display: none; - } } :host([position='top']), @@ -77,10 +86,10 @@ } [part='base'] { - height: 50vh; + height: var(--menu-height); min-height: initial; - width: 100%; - inset-inline: 0; + min-width: 100vw; + margin: revert; } } @@ -97,57 +106,73 @@ :host([position='start']) { [part='base'], [part~='mini'] { - inset-inline-start: 0; border-inline-end-style: solid; border-inline-end-width: rem(1px); } + + [part='base'] { + margin-inline-end: auto; + } } :host([position='end']) { [part='base'], [part~='mini'] { - inset-inline-end: 0; border-inline-start-style: solid; border-inline-start-width: rem(1px); } + + [part='base'] { + margin-inline-start: auto; + } } -:host(:not([open])[position='start']), -:host([dir='rtl']:not([open])[position='end']) { +:host([position='start']), +:host([dir='rtl'][position='end']) { --dir: #{direction($rtl: true)}; } -:host(:not([open])[position='end']), -:host([dir='rtl']:not([open])[position='start']) { +:host([position='end']), +:host([dir='rtl'][position='start']) { --dir: #{direction($rtl: false)}; } -:host(:not([open])[position='relative']) { - [part='base'] { - margin-inline-start: calc(var(--menu-full-width) * -1); - opacity: 1; - overflow: hidden; +:host(:not([open])[position='top']), +:host(:not([open])[position='bottom']) { + [part='mini'] { + display: none; } +} - [part='main'] { - opacity: 0; +:host(:not([open])[position='top']) { + [part='base'] { + translate: 0 calc(var(--menu-height) * -1); } } -:host(:not([open])[position='top']), -:host(:not([open])[position='bottom']) { +:host([open][position='top']) { [part='base'] { - translate: 0 -60vh; - } + translate: 0; - [part='mini'] { - display: none; + @starting-style { + translate: 0 calc(var(--menu-height) * -1); + } } } :host(:not([open])[position='bottom']) { [part='base'] { - translate: 0 60vh; + translate: 0 var(--menu-height); + } +} + +:host([open][position='bottom']) { + [part='base'] { + translate: 0; + + @starting-style { + translate: 0 var(--menu-height); + } } } @@ -159,26 +184,33 @@ } } -:host([open]) [part='mini'], -[part='mini hidden'] { - display: none; +:host(:not([open]):where([position='start'], [position='end'])) { + [part='base'] { + translate: calc(100% * var(--dir)) 0; + } } -:host([open]:not([position='relative'])) { +:host([open]:where([position='start'], [position='end'])) { [part='base'] { border-inline-end-style: solid; border-inline-end-width: rem(1px); + + @starting-style { + translate: calc(100% * var(--dir)) 0; + } } } -:host(:not([open])) { - [part='base'] { - translate: calc(100% * var(--dir)) 0; - opacity: 0; +:host([open][position='relative']) { + [part~='mini'] { + display: none; } +} - [part='overlay'] { - pointer-events: none; - opacity: 0; +:host(:not([open])) { + [part='base'] { + &::backdrop { + opacity: 0; + } } } diff --git a/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss b/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss index b50db8a582..91fe329dc6 100644 --- a/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss +++ b/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss @@ -4,13 +4,13 @@ $theme: $material; $background: var-get($theme, 'background') !default; -[part='overlay'] { - background: var-get($overlay-material, 'background-color'); -} - [part='base'] { border-radius: var-get($theme, 'border-radius'); background: $background; + + &::backdrop { + background: var-get($overlay-material, 'background-color'); + } } [part='mini'] { diff --git a/src/components/nav-drawer/themes/shared/container/nav-drawer.indigo.scss b/src/components/nav-drawer/themes/shared/container/nav-drawer.indigo.scss index 03491e2471..d733fd542e 100644 --- a/src/components/nav-drawer/themes/shared/container/nav-drawer.indigo.scss +++ b/src/components/nav-drawer/themes/shared/container/nav-drawer.indigo.scss @@ -6,7 +6,7 @@ $theme: $indigo; :host { --menu-mini-width: #{rem(48px)}; - [part='overlay'] { + [part='base']::backdrop { background: var-get($overlay-indigo, 'background-color'); } From a7ed38dd25edbf7c63215b2999199521e24d30c4 Mon Sep 17 00:00:00 2001 From: Simeon Simeonoff Date: Wed, 13 May 2026 21:48:48 +0300 Subject: [PATCH 4/8] refactor(themes): fix nav-drawer mini animations and border --- .../nav-drawer/themes/container.base.scss | 53 +++++++++---------- .../shared/container/nav-drawer.common.scss | 19 ++++--- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/components/nav-drawer/themes/container.base.scss b/src/components/nav-drawer/themes/container.base.scss index a10b1ca34f..bc5997431f 100644 --- a/src/components/nav-drawer/themes/container.base.scss +++ b/src/components/nav-drawer/themes/container.base.scss @@ -7,8 +7,7 @@ --transition-function: cubic-bezier(0.445, 0.05, 0.55, 0.95); --transition-duration: 0.35s; - display: grid; - grid-template-columns: repeat(2, max-content); + display: flex; flex-shrink: 0; font-family: var(--ig-font-family); @@ -21,8 +20,12 @@ overflow-x: hidden; } - [part~='mini'] { - min-width: var(--menu-mini-width); + [part='mini'] { + width: var(--menu-mini-width); + border-inline-end-style: solid; + border-inline-end-width: rem(1px); + opacity: 1; + transition-delay: 50ms; } [part='base'] { @@ -67,16 +70,22 @@ width: var(--menu-full-width); box-shadow: none; z-index: initial; - border-inline-end-style: solid; - border-inline-end-width: rem(1px); transition: margin var(--transition-function) var(--transition-duration); } - [part~='mini'] { - position: relative; + [part='base']:has(~ [part~='hidden']) { border-inline-end-style: solid; border-inline-end-width: rem(1px); } + + [part='mini'] { + position: relative; + overflow: hidden; + transition: + width var(--transition-function) var(--transition-duration), + min-width var(--transition-function) var(--transition-duration), + padding var(--transition-function) var(--transition-duration); + } } :host([position='top']), @@ -104,24 +113,12 @@ } :host([position='start']) { - [part='base'], - [part~='mini'] { - border-inline-end-style: solid; - border-inline-end-width: rem(1px); - } - [part='base'] { margin-inline-end: auto; } } :host([position='end']) { - [part='base'], - [part~='mini'] { - border-inline-start-style: solid; - border-inline-start-width: rem(1px); - } - [part='base'] { margin-inline-start: auto; } @@ -176,14 +173,6 @@ } } -:host([open][position='end']) { - [part='base'] { - border-inline-end: none; - border-inline-start-style: solid; - border-inline-start-width: rem(1px); - } -} - :host(:not([open]):where([position='start'], [position='end'])) { [part='base'] { translate: calc(100% * var(--dir)) 0; @@ -202,8 +191,14 @@ } :host([open][position='relative']) { + [part='base'] { + transition-delay: 50ms; + } + [part~='mini'] { - display: none; + width: 0; + min-width: 0; + padding-inline: 0; } } diff --git a/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss b/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss index 91fe329dc6..512c14f966 100644 --- a/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss +++ b/src/components/nav-drawer/themes/shared/container/nav-drawer.common.scss @@ -22,14 +22,21 @@ $background: var-get($theme, 'background') !default; box-shadow: var-get($theme, 'elevation'); } -:host([position='start']) [part~='base'], -:host([position='start']) [part~='mini'] { - border-inline-end-color: var-get($theme, 'border-color'); +:host([position='start']) { + [part~='base'], + [part~='mini'] { + border-inline-end-color: var-get($theme, 'border-color'); + } } -:host([position='end']) [part~='base'], -:host([position='end']) [part~='mini'] { - border-inline-start-color: var-get($theme, 'border-color'); +:host([position='end']) { + [part~='base'] { + border-inline-start-color: var-get($theme, 'border-color'); + } + + [part~='mini'] { + border-inline-end-color: var-get($theme, 'border-color'); + } } :host([position='relative']) { From a7db58f522086b9880895ec8b068e45dd6924370 Mon Sep 17 00:00:00 2001 From: Simeon Simeonoff Date: Wed, 13 May 2026 21:58:57 +0300 Subject: [PATCH 5/8] refactor(nav-drawer): always show mini when present --- src/components/nav-drawer/themes/container.base.scss | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/nav-drawer/themes/container.base.scss b/src/components/nav-drawer/themes/container.base.scss index bc5997431f..2430f18e68 100644 --- a/src/components/nav-drawer/themes/container.base.scss +++ b/src/components/nav-drawer/themes/container.base.scss @@ -29,6 +29,7 @@ } [part='base'] { + box-sizing: border-box; width: var(--menu-full-width); margin: unset; border-color: transparent; @@ -134,13 +135,6 @@ --dir: #{direction($rtl: false)}; } -:host(:not([open])[position='top']), -:host(:not([open])[position='bottom']) { - [part='mini'] { - display: none; - } -} - :host(:not([open])[position='top']) { [part='base'] { translate: 0 calc(var(--menu-height) * -1); From 15e5138d1cb3cf0f57a999e402b4917db376f6a0 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 14 May 2026 09:01:13 +0300 Subject: [PATCH 6/8] docs: Updated API docs and CHANGELOG for nav-drawer --- CHANGELOG.md | 10 ++++++ src/components/nav-drawer/nav-drawer.ts | 42 ++++++++++++++++--------- stories/nav-drawer.stories.ts | 19 ++++++++--- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 178ce9d76e..ddb79816c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- #### Nav Drawer + - The drawer for non-relative positions is now implemented using a native `` element, 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. + - The `mini` slot content is now always visible whenever it is provided, regardless of the drawer's position or open state. + +### 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.ts b/src/components/nav-drawer/nav-drawer.ts index 9c82ac0137..3f82ac559b 100644 --- a/src/components/nav-drawer/nav-drawer.ts +++ b/src/components/nav-drawer/nav-drawer.ts @@ -22,20 +22,28 @@ export interface IgcNavDrawerComponentEventMap { } /** - * Represents a side navigation container that provides - * quick access between views. + * `igc-nav-drawer` is a side navigation container that provides + * quick access between views within an application. + * + * For non-relative positions the drawer is rendered as a native modal `` element, + * providing built-in accessibility support and Escape key handling. + * In `relative` position mode it renders as an inline element and applies `inert` + * to the content when closed. + * + * When content is provided in the `mini` slot, a compact icon-only variant is + * displayed alongside the main drawer. * * @element igc-nav-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 - The default slot for the igc-navigation-drawer. - * @slot mini - The slot for the mini variant of the igc-navigation-drawer. + * @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. A `` element for non-relative positions, a `
` for relative. + * @csspart main - The main content container of the drawer. + * @csspart mini - The mini variant container of the drawer. */ export default class IgcNavDrawerComponent extends EventEmitterMixin< IgcNavDrawerComponentEventMap, @@ -74,7 +82,13 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< //#region Public properties /** - * The position of the drawer. + * 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' @@ -83,7 +97,7 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< public position: NavDrawerPosition = 'start'; /** - * Determines whether the drawer is opened. + * Whether the drawer is open. * * @attr open * @default false @@ -105,7 +119,7 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< //#endregion - //#region Lifecycle + //#region Lit Lifecycle constructor() { super(); @@ -188,7 +202,7 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< //#region Public API - /** Opens the drawer. */ + /** Opens the drawer. Returns `true` if the operation was successful, `false` if the drawer was already open. */ public async show(): Promise { if (this.open) { return false; @@ -200,7 +214,7 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< return true; } - /** Closes the drawer. */ + /** Closes the drawer. Returns `true` if the operation was successful, `false` if the drawer was already closed. */ public async hide(): Promise { if (!this.open) { return false; @@ -212,8 +226,8 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< return true; } - /** Toggles the open state of the drawer. */ - public async toggle(): Promise { + /** Toggles the open state of the drawer. Delegates to `show()` or `hide()` depending on the current state. */ + public toggle(): Promise { return this.open ? this.hide() : this.show(); } diff --git a/stories/nav-drawer.stories.ts b/stories/nav-drawer.stories.ts index b3433eba10..1ba211bada 100644 --- a/stories/nav-drawer.stories.ts +++ b/stories/nav-drawer.stories.ts @@ -21,7 +21,7 @@ const metadata: Meta = { docs: { description: { component: - 'Represents a side navigation container that provides\nquick access between views.', + '`igc-nav-drawer` is a side navigation container that provides\nquick access between views within an application.\n\nFor non-relative positions the drawer is rendered as a native modal `` element,\nproviding built-in accessibility support and Escape key handling.\nIn `relative` position mode it renders as an inline element and applies `inert`\nto the content when closed.\n\nWhen content is provided in the `mini` slot, a compact icon-only variant is\ndisplayed alongside the main drawer.', }, }, actions: { handles: ['igcClosing', 'igcClosed'] }, @@ -29,14 +29,15 @@ const metadata: Meta = { argTypes: { position: { type: '"start" | "end" | "top" | "bottom" | "relative"', - description: 'The position of the drawer.', + description: + 'Sets the position of the drawer.\n\n- `start` — anchored to the inline-start edge (default).\n- `end` — anchored to the inline-end edge.\n- `top` — anchored to the block-start edge.\n- `bottom` — anchored to the block-end edge.\n- `relative` — rendered inline within the page flow; no modal backdrop.', options: ['start', 'end', 'top', 'bottom', 'relative'], control: { type: 'select' }, table: { defaultValue: { summary: 'start' } }, }, open: { type: 'boolean', - description: 'Determines whether the drawer is opened.', + description: 'Whether the drawer is open.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -54,9 +55,17 @@ const metadata: Meta = { export default metadata; interface IgcNavDrawerArgs { - /** The position of the drawer. */ + /** + * 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. + */ position: 'start' | 'end' | 'top' | 'bottom' | 'relative'; - /** Determines whether the drawer is opened. */ + /** Whether the drawer is open. */ open: boolean; /** * Determines whether the drawer should remain open when the Escape key is pressed. From 2a23f4871de6d310d95e4f497011238da1c15bab Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 15 May 2026 12:55:05 +0300 Subject: [PATCH 7/8] feat: Improved nav drawer accessibility --- .prettierignore | 1 + src/components/nav-drawer/nav-drawer.spec.ts | 8 +++--- src/components/nav-drawer/nav-drawer.ts | 25 ++++++++++++++++++- stories/nav-drawer.stories.ts | 26 +++++++++++++++++++- 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..dd449725e1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.md diff --git a/src/components/nav-drawer/nav-drawer.spec.ts b/src/components/nav-drawer/nav-drawer.spec.ts index fbe93d48bc..fca79f074d 100644 --- a/src/components/nav-drawer/nav-drawer.spec.ts +++ b/src/components/nav-drawer/nav-drawer.spec.ts @@ -69,7 +69,7 @@ describe('Navigation Drawer', () => { navDrawer = await createNavDrawer(); expect(navDrawer).shadowDom.equal(` - +
@@ -80,7 +80,7 @@ describe('Navigation Drawer', () => { `); }); - it('renders div-based shadow DOM for relative position', async () => { + it('renders nav-based shadow DOM for relative position', async () => { navDrawer = await createNavDrawer(html` @@ -88,11 +88,11 @@ describe('Navigation Drawer', () => { `); expect(navDrawer).shadowDom.equal(` -
+
+
diff --git a/src/components/nav-drawer/nav-drawer.ts b/src/components/nav-drawer/nav-drawer.ts index 3f82ac559b..7cd59f5c80 100644 --- a/src/components/nav-drawer/nav-drawer.ts +++ b/src/components/nav-drawer/nav-drawer.ts @@ -1,6 +1,7 @@ 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'; @@ -117,6 +118,20 @@ export default class IgcNavDrawerComponent extends EventEmitterMixin< @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 `
+ ${this._renderMiniVariant()} `; } diff --git a/stories/nav-drawer.stories.ts b/stories/nav-drawer.stories.ts index 1ba211bada..4e206ce343 100644 --- a/stories/nav-drawer.stories.ts +++ b/stories/nav-drawer.stories.ts @@ -10,6 +10,7 @@ import { registerIcon, } from 'igniteui-webcomponents'; import { range } from 'lit/directives/range.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; defineComponents(IgcIconComponent, IgcNavDrawerComponent, IgcButtonComponent); @@ -48,8 +49,20 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + ariaLabel: { + type: 'string', + description: + 'Sets an accessible label for the drawer.\n\nIn non-relative positions this label is applied to the modal `` element.\nIn `relative` position it labels the `