Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- In dialog mode, a canceled `igcClosing` now keeps the dialog open, and an outside click commits the range only when the close goes on. Before, an outside click closed the dialog, and `open` stayed `true`.
- During the exit, the calendar and the predefined ranges ignore a selection, Done, Cancel and Escape do nothing, and a `show()` keeps the picker open without an animation. During the entrance or the exit, `open` set to `false` and then to `true` keeps the picker open, and `open` set to `false` closes the dialog at once.
- #### Date range picker
- **Behavioral change:** In dialog mode, Done now emits `igcChange` after `igcClosing`, and Done and Cancel change the range only when the close goes on, as Escape and an outside click do. So a canceled `igcClosing` keeps the dialog open with the selected range. Before, Done emitted `igcChange` before `igcClosing` and committed the range, and Cancel restored it, also when a handler canceled the close. A `hide()` after a canceled close still does not commit or restore, so the selected range stays the value, with no `igcChange`. To ask for a confirmation before a close, restore the range through `value` when the user declines.
- **Behavioral change:** The **Last 7 Days** predefined range selects 7 days: today and the 6 days before it. Before, it selected 8 days. [#2528](https://github.com/IgniteUI/igniteui-webcomponents/issues/2528)
- The predefined ranges now use the date of the click. Before, they used the date of the first render, so a page that stayed open past midnight selected old ranges.
- #### Date range picker, Date time input, Mask input
Expand All @@ -214,6 +215,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- In the date time input, `stepUp()` and `stepDown()` now update `value` at once, also while the input has the focus, as `setRangeText()` does. A step of a part that is not in the input format, such as the minutes of `yyyy/MM/dd`, now stays after blur. Before, `value` changed only on blur, and that step was lost. The arrow keys and the mouse wheel still change only the text until blur.
- #### Dialog
- A `hide()` in the same task as `show()` now closes the dialog at once. Before, the dialog showed and then faded out.
- A dialog that you move in the document, or remove and append again, now keeps its state. An open dialog, also one in its exit, is modal again after the append, with its backdrop. A dialog that closed while it was out of the document, also at the end of an exit, stays closed, and a `show()` while it was out makes it modal when you append it. Before, the inner `<dialog>` came back as a non-modal dialog with no backdrop, or stayed open after a close, and the next `show()` could throw an `InvalidStateError`. This also applies to the date picker and the date range picker in dialog mode.
- A change of `open` against a running `show()` or `hide()` now ends it, and the dialog keeps the new state. `open` stays `true` during the exit, so set it to `false` and then to `true` to keep the dialog open. The method, and `toggle()`, then resolve `false`, and a close by the user emits `igcClosing` but not `igcClosed`. Before, the exit closed the dialog when its animation ended, for example when a date picker in dialog mode opened again quickly. After `open = false` during `show()`, a new `show()` did nothing until the entrance animation ended.
- An `aria-label` on the dialog is now its only accessible name. Before, the inner `<dialog>` also got `aria-labelledby` with the text of the label, which is not a valid ID reference. [#2429](https://github.com/IgniteUI/igniteui-webcomponents/pull/2429)
- When the platform closes the dialog while `open` stays `true`, the dialog now opens again. An example is a second Escape when an `igcClosing` handler cancels the close. Before, only `keepOpenOnEscape` did this. In other cases, `open` stayed `true` with a closed dialog, the backdrop still covered the page, and `show()` did nothing. [#2441](https://github.com/IgniteUI/igniteui-webcomponents/pull/2441)
Expand Down
24 changes: 16 additions & 8 deletions src/components/date-picker/date-picker.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,20 @@ export abstract class IgcDatePickerBaseComponent<
this._emitChangeIfDirty();
}

/**
* Closes the picker with the close events. `apply` commits or reverts the value, and runs only when the close goes
* on, so a canceled `igcClosing` changes nothing. A running close already decided the value, so the call does nothing
* then.
*/
protected _closeWith(apply: () => void): Promise<boolean> {
if (!this.open || this._isClosing) return Promise.resolve(false);

const closed = this._hide(true);
// The exit runs, or a close without one is done.
if (this._isClosing || !this.open) apply();
return closed;
}

/** Emits `igcChange` if the value has moved away from the last committed one. */
protected _emitChangeIfDirty(): void {
if (!equal(this._value, this._oldValue)) {
Expand Down Expand Up @@ -688,13 +702,8 @@ export abstract class IgcDatePickerBaseComponent<
//#region Event handlers

protected async _onEscapeKey(): Promise<void> {
// The running close decides the value.
if (!this.open || this._isClosing) return;

const closed = this._hide(true);
// Revert as the close starts, as Cancel does, so a reopen during the exit keeps the old value.
if (this._isClosing || !this.open) this._handleDismiss();
if (await closed) this._focusInput();
if (await this._closeWith(() => this._handleDismiss())) this._focusInput();
}

protected override async _handleAnchorClick(): Promise<void> {
Expand Down Expand Up @@ -741,8 +750,7 @@ export abstract class IgcDatePickerBaseComponent<
protected _handleDialogClosing(event: Event): void {
event.preventDefault();
event.stopPropagation();
this._hide(true);
if (this._isClosing) this._onDialogClosing();
this._closeWith(() => this._onDialogClosing());
}

//#endregion
Expand Down
30 changes: 30 additions & 0 deletions src/components/date-picker/date-picker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,36 @@ describe('Date picker', () => {

expect(states()).to.eql([false, false, false]);
});

it('opens a modal dialog again after it is removed during the exit', async () => {
const parent = picker.parentElement!;
const closed = oneEvent(picker, 'igcClosed');

await startExit();
picker.remove();
await closed;

parent.append(picker);
await settle();
expect(states()).to.eql([false, false, false]);

expect(await picker.show()).to.be.true;
expect(nativeDialog.matches(':modal')).to.be.true;
});

it('stays open as a modal when it is appended again and shown during the exit', async () => {
const parent = picker.parentElement!;

await startExit();
picker.remove();
parent.append(picker);

expect(await picker.show()).to.be.true;
await settle();

expectSteadyOpen();
expect(nativeDialog.matches(':modal')).to.be.true;
});
});
});

Expand Down
4 changes: 3 additions & 1 deletion src/components/date-picker/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,9 @@ Grouped as `Uncommitted edits - issue #1346` in the suite.
45. In dialog mode, the open and the close play the dialog animations, `igcOpened` and `igcClosed` follow them, and
`open` stays `true` during the exit. A canceled `igcClosing`, a `show()`, or `open` set to `false` and then `true`
during the entrance or the exit, keeps the dialog open; `open` set to `false` closes it at once, also in the task
of `show()`; a `hide()` in the task of `show()` shows no dialog; the calendar ignores a selection during the exit.
of `show()`; a `hide()` in the task of `show()` shows no dialog; the calendar ignores a selection during the exit;
a picker removed during the exit opens a modal dialog again after an append, and a picker appended again and
shown during the exit stays open as a modal.

### Readonly state

Expand Down
65 changes: 65 additions & 0 deletions src/components/date-range-picker/date-range-picker.common.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,71 @@ describe('Date range picker - common tests for single and two inputs mode', () =
expect(eventSpy).not.calledWith('igcChange');
});

const cancelButton = () =>
picker.renderRoot.querySelector<HTMLElement>(
'igc-button[slot="footer"]'
)!;
const doneButton = () =>
picker.renderRoot.querySelector<HTMLElement>(
'igc-button[slot="footer"]:last-of-type'
)!;
const cancelNextClose = () =>
picker.addEventListener(
'igcClosing',
(event) => event.preventDefault(),
{ once: true }
);

it('commits nothing on Done when `igcClosing` is canceled (dialog)', async () => {
const { value, eventSpy } = await selectDraftInDialog();
const draft = picker.value;
cancelNextClose();

simulateClick(doneButton());
await elementUpdated(picker);

expect(picker.open).to.be.true;
expect(picker.value).to.eql(draft);
expect(eventSpy).not.calledWith('igcChange');

simulateClick(cancelButton());
await elementUpdated(picker);

expect(picker.value).to.eql(value);
expect(eventSpy).not.calledWith('igcChange');
});

it('keeps the draft on Cancel when `igcClosing` is canceled (dialog)', async () => {
const { eventSpy } = await selectDraftInDialog();
const draft = picker.value;
cancelNextClose();

simulateClick(cancelButton());
await elementUpdated(picker);

expect(picker.open).to.be.true;
expect(picker.value).to.eql(draft);

simulateClick(doneButton());
await elementUpdated(picker);

expect(eventSpy).calledWith('igcChange', { detail: draft });
});

it('emits `igcChange` after `igcClosing` on Done (dialog)', async () => {
const { eventSpy } = await selectDraftInDialog();
eventSpy.resetHistory();

simulateClick(doneButton());
await waitUntil(() => eventSpy.calledWith('igcClosed'));

expect(eventSpy.args.map(([name]) => name)).to.eql([
'igcClosing',
'igcChange',
'igcClosed',
]);
});

it('keeps the range of Escape when the dialog opens again during the exit (dialog)', async () => {
const { value, eventSpy } = await selectDraftInDialog();

Expand Down
9 changes: 2 additions & 7 deletions src/components/date-range-picker/date-range-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,17 +535,12 @@ export default class IgcDateRangePickerComponent extends I18nMixin(

// #region Event handlers

// The running close decides the value.
protected _dialogCancel() {
if (this._isClosing) return;
this._revertValue();
this._hide(true);
this._closeWith(() => this._revertValue());
}

protected _dialogDone() {
if (this._isClosing) return;
this._emitChangeIfDirty();
this._hide(true);
this._closeWith(() => this._emitChangeIfDirty());
}

protected _handleInput(event: CustomEvent<string>) {
Expand Down
14 changes: 10 additions & 4 deletions src/components/date-range-picker/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
| 14 | 2026-10-29 | The `overlay` part is removed; the dialog backdrop is the native `::backdrop` of `dialog-base` |
| 15 | 2026-11-02 | A `focus()` call before the first render applies after the render; `focus()` and `blur()` in the API |
| 16 | 2026-11-04 | In dialog mode, an open and a close play the animations of the dialog; a canceled `igcClosing` keeps the dialog open |
| 17 | 2026-11-05 | Done and Cancel change the range only when the close goes on; Done emits `igcChange` after `igcClosing` |

## Overview

Expand Down Expand Up @@ -374,9 +375,10 @@ as touched, so an invalid value shows its invalid styles at once, also when the
**Opening and closing the dialog.** The dialog fades in on an open, and `igcOpened` follows the entrance. Cancel,
Done, <kbd>Escape</kbd> and an outside click play the exit animation of the dialog, and `open` stays `true` until it
ends; the calendar and the range chips ignore a selection then, and Done, Cancel and <kbd>Escape</kbd> do nothing.
A canceled `igcClosing` keeps the dialog open. Done commits before `igcClosing`, but an outside click commits only
when the close goes on, so after a canceled outside click, Cancel still restores the range. The date picker suite
covers the shared behavior.
Done, Cancel, <kbd>Escape</kbd> and an outside click emit `igcClosing` first, and commit or restore the range only
when the close goes on. So a canceled `igcClosing` keeps the dialog open with the selected range, and Done emits
`igcClosing`, `igcChange` and `igcClosed` in this order. A `hide()` after a canceled close does not commit or
restore: the selected range stays the value, with no `igcChange`. The date picker suite covers the shared behavior.

**Readonly.** Keyboard navigation, toggling the calendar and clearing through the clear icon are all disabled, and
the calendar and clear icons appear visually disabled.
Expand Down Expand Up @@ -591,7 +593,8 @@ input and two inputs suites run with reduced motion, so the dialog does not fade
3. Methods - `show`, `hide`, `toggle`, `select` and `clear`.
4. Interactions - selection through the calendar, keyboard navigation, and interactions with the show icon. A value
set from code while the dialog is open, also with `select()`, is not reported when Done commits. After a canceled
close on an outside click, Cancel restores the range. <kbd>Escape</kbd> restores the range as the close starts, so
close on an outside click or on Done, Cancel restores the range; after a canceled close on Cancel, Done commits it.
Done emits `igcChange` after `igcClosing`. <kbd>Escape</kbd> restores the range as the close starts, so
Done or a reopen during the exit keeps it. Done, Cancel and <kbd>Escape</kbd> do nothing during the exit of `hide()`.
5. Readonly state, in dropdown and in dialog mode.
6. Predefined ranges - the built-in chips and the custom ranges, and a chip during the exit of the dialog.
Expand Down Expand Up @@ -673,6 +676,9 @@ The focus after the form checks and the `invalid` event rules come from the form
- `min`, `max` and `disabledDates` restrict the calendar and drive validation, but they do not prevent an
out-of-range date from being typed in; such a value invalidates the component instead.
- `hideHeader`, `headerOrientation` and the `title` slot are only in effect in dialog mode.
- In dialog mode, `hide()`, `toggle()` and a change of `open` do not commit or restore the selected range, also after
a canceled `igcClosing`. An application that cancels the close, for example to ask for a confirmation, restores the
range itself, through `value`.

## Accessibility

Expand Down
95 changes: 95 additions & 0 deletions src/components/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,101 @@ describe('Dialog', () => {
expect(dialog.renderRoot.querySelector('dialog')!.open).to.be.false;
expect(await closing).to.be.true;
});

/** Appends the dialog again and checks that `show()` opens it as a modal. */
const expectModalAfterAppend = async (parent: Element) => {
parent.append(dialog);
await elementUpdated(dialog);

const native = dialog.renderRoot.querySelector('dialog')!;
expect(dialog.open).to.be.false;
expect(native.open).to.be.false;

expect(await dialog.show()).to.be.true;
expect(native.matches(':modal')).to.be.true;
};

it('closes when it is removed during the exit', async () => {
await dialog.show();
const parent = dialog.parentElement!;

const closing = dialog.hide();
dialog.remove();

expect(await closing).to.be.true;
await elementUpdated(dialog);
await expectModalAfterAppend(parent);
});

it('closes when `open` is set to `false` while it is not in the document', async () => {
await dialog.show();
const parent = dialog.parentElement!;

dialog.remove();
dialog.open = false;
await elementUpdated(dialog);

await expectModalAfterAppend(parent);
});

it('opens as a modal on `open = false` out of the document, an append and `show()` in one task', async () => {
await dialog.show();
const native = dialog.renderRoot.querySelector('dialog')!;
const parent = dialog.parentElement!;

dialog.remove();
dialog.open = false;
parent.append(dialog);
expect(native.open).to.be.false;

expect(await dialog.show()).to.be.true;
expect(native.matches(':modal')).to.be.true;
});

it('stays modal when it is appended again during the exit', async () => {
await dialog.show();
const native = dialog.renderRoot.querySelector('dialog')!;
const parent = dialog.parentElement!;

const closing = dialog.hide();
dialog.remove();
parent.append(dialog);

expect(native.matches(':modal')).to.be.true;
expect(await dialog.show()).to.be.true;
expect(await closing).to.be.false;
expect(native.matches(':modal')).to.be.true;
});

it('stays modal when it moves while open', async () => {
await dialog.show();
const native = dialog.renderRoot.querySelector('dialog')!;

dialog.parentElement!.append(dialog);
expect(native.matches(':modal')).to.be.true;

dialog.open = false;
dialog.open = true;
await elementUpdated(dialog);
expect(native.matches(':modal')).to.be.true;

expect(await dialog.hide()).to.be.true;
expect(native.open).to.be.false;
});

it('opens as a modal on an append after `show()` while it is not in the document', async () => {
const native = dialog.renderRoot.querySelector('dialog')!;
const parent = dialog.parentElement!;

dialog.remove();
const opening = dialog.show();
await elementUpdated(dialog);
expect(native.open).to.be.false;

parent.append(dialog);
expect(native.matches(':modal')).to.be.true;
expect(await opening).to.be.true;
});
});

describe('Events & Behaviors', () => {
Expand Down
30 changes: 26 additions & 4 deletions src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ export default class IgcDialogComponent extends I18nMixin(
addToggleCommands(this);
}

public override connectedCallback(): void {
super.connectedCallback();

if (this.hasUpdated) {
this._syncDialog();
}
}

protected override updated(properties: PropertyValues<this>): void {
if (properties.has('open')) {
this._syncDialog();
Expand Down Expand Up @@ -271,12 +279,26 @@ export default class IgcDialogComponent extends I18nMixin(
}

/**
* Opens or closes the dialog to match `open`. It also runs on the `close`
* event, so a close that the component cannot cancel, such as a second
* Escape, does not leave it open with a closed dialog.
* Makes the dialog modal when `open` is set, and closes it otherwise. It runs
* on a change of `open`, on a reconnect, and on the `close` event, so a close
* that the component cannot cancel, such as a second Escape, does not leave
* it open with a closed dialog.
*/
private _syncDialog(): void {
this.open ? this._dialog?.showModal() : this._dialog?.close();
// Not `_dialog`: Lit empties the ref while the host is out of the document.
const dialog = this.renderRoot.querySelector('dialog');

if (!dialog || (this.open ? dialog.matches(':modal') : !dialog.open)) {
return;
}

// A removal takes the dialog out of the top layer, so it can be open but
// not modal, and `showModal()` then throws. A dialog out of the document
// cannot be modal, so it waits for the reconnect.
dialog.close();
if (this.open && this.isConnected) {
dialog.showModal();
}
}

//#endregion
Expand Down
Loading
Loading