-
-
Notifications
You must be signed in to change notification settings - Fork 135
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3492 from grebaldi/bugfix/2925/dialog-click-outside
BUGFIX: Limit click-outside handling for dialogs to their semi-transparent overlay
- Loading branch information
Showing
9 changed files
with
394 additions
and
130 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
239 changes: 239 additions & 0 deletions
239
packages/react-ui-components/src/Dialog/DialogManager.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,239 @@ | ||
import { DialogManager, Dialog, EventRoot } from './DialogManager'; | ||
|
||
describe('DialogManager', () => { | ||
describe('#register', () => { | ||
it('adds the `dialogManager.handleKeydown` event listener to the given event root if invoked for the first time', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog: Dialog = { close: jest.fn() }; | ||
|
||
dialogManager.register(dialog); | ||
|
||
expect(eventRoot.addEventListener).toBeCalledWith( | ||
'keydown', | ||
dialogManager.handleKeydown | ||
); | ||
}); | ||
|
||
it('does not add the event listener to the given event root on subsequent calls', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog1: Dialog = { close: jest.fn() }; | ||
const dialog2: Dialog = { close: jest.fn() }; | ||
const dialog3: Dialog = { close: jest.fn() }; | ||
|
||
dialogManager.register(dialog1); | ||
dialogManager.register(dialog2); | ||
dialogManager.register(dialog3); | ||
|
||
expect(eventRoot.addEventListener).toBeCalledTimes(1); | ||
}); | ||
|
||
it('re-adds the event listener to the given event root if invoked after all dialogs have been closed', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog1: Dialog = { close: jest.fn() }; | ||
const dialog2: Dialog = { close: jest.fn() }; | ||
const dialog3: Dialog = { close: jest.fn() }; | ||
|
||
// Register dialogs | ||
dialogManager.register(dialog1); | ||
dialogManager.register(dialog2); | ||
dialogManager.register(dialog3); | ||
|
||
// Close all dialogs | ||
dialogManager.closeLatest(); | ||
dialogManager.closeLatest(); | ||
dialogManager.closeLatest(); | ||
|
||
expect(eventRoot.addEventListener).toBeCalledTimes(1); | ||
|
||
// Register another dialog | ||
dialogManager.register(dialog1); | ||
|
||
expect(eventRoot.addEventListener).toBeCalledTimes(2); | ||
}); | ||
}); | ||
|
||
describe('#handleKeydown', () => { | ||
it('invokes `dialogManager.closeLatest` if the given event was an Escape-Keypress', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = Object.assign( | ||
new DialogManager({ eventRoot }), | ||
{ closeLatest: jest.fn() } | ||
); | ||
const event: KeyboardEvent = { | ||
key: 'Escape', | ||
} as KeyboardEvent; | ||
|
||
dialogManager.handleKeydown(event); | ||
|
||
expect(dialogManager.closeLatest).toBeCalled(); | ||
}); | ||
it('does not invoke `dialogManager.closeLatest` if the given event was not an Escape-Keypress', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = Object.assign( | ||
new DialogManager({ eventRoot }), | ||
{ closeLatest: jest.fn() } | ||
); | ||
|
||
dialogManager.handleKeydown({ | ||
key: 'A', | ||
} as KeyboardEvent); | ||
|
||
expect(dialogManager.closeLatest).not.toBeCalled(); | ||
|
||
dialogManager.handleKeydown({ | ||
key: 'Foo', | ||
} as KeyboardEvent); | ||
|
||
expect(dialogManager.closeLatest).not.toBeCalled(); | ||
}); | ||
}); | ||
|
||
describe('#closeLatest', () => { | ||
it('picks the latest registered dialog and invokes `dialog.close` on it', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog1: Dialog = { close: jest.fn() }; | ||
const dialog2: Dialog = { close: jest.fn() }; | ||
const dialog3: Dialog = { close: jest.fn() }; | ||
|
||
dialogManager.register(dialog1); | ||
dialogManager.register(dialog2); | ||
dialogManager.register(dialog3); | ||
|
||
dialogManager.closeLatest(); | ||
expect(dialog1.close).not.toHaveBeenCalled(); | ||
expect(dialog2.close).not.toHaveBeenCalled(); | ||
expect(dialog3.close).toHaveBeenCalled(); | ||
|
||
dialogManager.closeLatest(); | ||
expect(dialog1.close).not.toHaveBeenCalled(); | ||
expect(dialog2.close).toHaveBeenCalled(); | ||
expect(dialog3.close).toHaveBeenCalledTimes(1); | ||
|
||
dialogManager.closeLatest(); | ||
expect(dialog1.close).toHaveBeenCalled(); | ||
expect(dialog2.close).toHaveBeenCalledTimes(1); | ||
expect(dialog3.close).toHaveBeenCalledTimes(1); | ||
|
||
dialogManager.closeLatest(); | ||
expect(dialog1.close).toHaveBeenCalledTimes(1); | ||
expect(dialog2.close).toHaveBeenCalledTimes(1); | ||
expect(dialog3.close).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('removes the `dialogManager.handleKeydown` event listener from the given event root once all dialogs have been closed', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog1: Dialog = { close: jest.fn() }; | ||
const dialog2: Dialog = { close: jest.fn() }; | ||
const dialog3: Dialog = { close: jest.fn() }; | ||
|
||
dialogManager.register(dialog1); | ||
dialogManager.register(dialog2); | ||
dialogManager.register(dialog3); | ||
|
||
dialogManager.closeLatest(); | ||
dialogManager.closeLatest(); | ||
|
||
expect(eventRoot.removeEventListener).not.toBeCalled(); | ||
|
||
dialogManager.closeLatest(); | ||
|
||
expect(eventRoot.removeEventListener).toBeCalledWith( | ||
'keydown', | ||
dialogManager.handleKeydown | ||
); | ||
|
||
dialogManager.closeLatest(); | ||
|
||
expect(eventRoot.removeEventListener).toBeCalledTimes(1); | ||
}); | ||
|
||
it('closes a registered dialog only once, even if has been registered twice - in which case it uses order of first registration', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog1: Dialog = { close: jest.fn() }; | ||
const dialog2: Dialog = { close: jest.fn() }; | ||
|
||
dialogManager.register(dialog1); | ||
dialogManager.register(dialog2); | ||
|
||
// Register dialog 1 again | ||
dialogManager.register(dialog1); | ||
|
||
dialogManager.closeLatest(); | ||
expect(dialog1.close).not.toBeCalled(); | ||
|
||
dialogManager.closeLatest(); | ||
expect(dialog1.close).toBeCalled(); | ||
}); | ||
}); | ||
|
||
describe('#forget', () => { | ||
it('removes a dialog from the stack', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog1: Dialog = { close: jest.fn() }; | ||
const dialog2: Dialog = { close: jest.fn() }; | ||
const dialog3: Dialog = { close: jest.fn() }; | ||
|
||
dialogManager.register(dialog1); | ||
dialogManager.register(dialog2); | ||
dialogManager.register(dialog3); | ||
|
||
dialogManager.forget(dialog2); | ||
|
||
dialogManager.closeLatest(); | ||
dialogManager.closeLatest(); | ||
dialogManager.closeLatest(); | ||
|
||
expect(dialog1.close).toHaveBeenCalled(); | ||
expect(dialog2.close).not.toHaveBeenCalled(); | ||
expect(dialog3.close).toHaveBeenCalled(); | ||
}); | ||
|
||
it('removes the `dialogManager.handleKeydown` event listener from the given event root if the last remaining dialog is removed', () => { | ||
const eventRoot: EventRoot = { | ||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
}; | ||
const dialogManager = new DialogManager({ eventRoot }); | ||
const dialog: Dialog = { close: jest.fn() }; | ||
|
||
dialogManager.register(dialog); | ||
dialogManager.forget(dialog); | ||
|
||
expect(eventRoot.removeEventListener).toHaveBeenCalled(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
export interface EventRoot { | ||
addEventListener: Document['addEventListener']; | ||
removeEventListener: Document['removeEventListener']; | ||
} | ||
|
||
export interface Dialog { | ||
close: () => void; | ||
} | ||
|
||
export class DialogManager { | ||
private dialogs: Dialog[] = []; | ||
|
||
constructor(private readonly deps: { eventRoot: EventRoot }) {} | ||
|
||
public register(dialog: Dialog): void { | ||
if (this.dialogs.length === 0) { | ||
this.deps.eventRoot.addEventListener('keydown', this.handleKeydown); | ||
} | ||
|
||
if (!this.dialogs.includes(dialog)) { | ||
this.dialogs.push(dialog); | ||
} | ||
} | ||
|
||
public forget(dialog: Dialog): void { | ||
this.dialogs = this.dialogs.filter((d) => d !== dialog); | ||
this.removeHandleKeydownEventListenerIfNecessary(); | ||
} | ||
|
||
public readonly handleKeydown = (event: KeyboardEvent): void => { | ||
if (event.key === 'Escape') { | ||
this.closeLatest(); | ||
} | ||
} | ||
|
||
public closeLatest(): void { | ||
const dialog = this.dialogs.pop(); | ||
if (dialog) { | ||
dialog.close(); | ||
this.removeHandleKeydownEventListenerIfNecessary(); | ||
} | ||
} | ||
|
||
private removeHandleKeydownEventListenerIfNecessary(): void { | ||
if (this.dialogs.length === 0) { | ||
this.deps.eventRoot.removeEventListener( | ||
'keydown', | ||
this.handleKeydown | ||
); | ||
} | ||
} | ||
} |
Oops, something went wrong.