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
36 changes: 34 additions & 2 deletions core/dropdowndiv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ export function setColour(backgroundColour: string, borderColour: string) {
* passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. Defaults
* to true.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered below block; false if above.
*/
export function showPositionedByBlock<T>(
Expand All @@ -221,11 +223,13 @@ export function showPositionedByBlock<T>(
opt_onHide?: () => void,
opt_secondaryYOffset?: number,
manageEphemeralFocus: boolean = true,
autoCloseOnLostFocus: boolean = true,
): boolean {
return showPositionedByRect(
getScaledBboxOfBlock(block),
field as Field,
manageEphemeralFocus,
autoCloseOnLostFocus,
opt_onHide,
opt_secondaryYOffset,
);
Expand All @@ -245,19 +249,23 @@ export function showPositionedByBlock<T>(
* passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. Defaults
* to true.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered below block; false if above.
*/
export function showPositionedByField<T>(
field: Field<T>,
opt_onHide?: () => void,
opt_secondaryYOffset?: number,
manageEphemeralFocus: boolean = true,
autoCloseOnLostFocus: boolean = true,
): boolean {
positionToField = true;
return showPositionedByRect(
getScaledBboxOfField(field as Field),
field as Field,
manageEphemeralFocus,
autoCloseOnLostFocus,
opt_onHide,
opt_secondaryYOffset,
);
Expand Down Expand Up @@ -302,12 +310,15 @@ function getScaledBboxOfField(field: Field): Rect {
* according to the drop-down div's lifetime. Note that if a false value is
* passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered below block; false if above.
*/
function showPositionedByRect(
bBox: Rect,
field: Field,
manageEphemeralFocus: boolean,
autoCloseOnLostFocus: boolean,
opt_onHide?: () => void,
opt_secondaryYOffset?: number,
): boolean {
Expand Down Expand Up @@ -335,6 +346,7 @@ function showPositionedByRect(
secondaryX,
secondaryY,
manageEphemeralFocus,
autoCloseOnLostFocus,
opt_onHide,
);
}
Expand All @@ -357,6 +369,8 @@ function showPositionedByRect(
* @param opt_onHide Optional callback for when the drop-down is hidden.
* @param manageEphemeralFocus Whether ephemeral focus should be managed
* according to the widget div's lifetime.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered at the primary origin point.
* @internal
*/
Expand All @@ -368,6 +382,7 @@ export function show<T>(
secondaryX: number,
secondaryY: number,
manageEphemeralFocus: boolean,
autoCloseOnLostFocus: boolean,
opt_onHide?: () => void,
): boolean {
owner = newOwner as Field;
Expand All @@ -394,7 +409,18 @@ export function show<T>(
// Ephemeral focus must happen after the div is fully visible in order to
// ensure that it properly receives focus.
if (manageEphemeralFocus) {
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
const autoCloseCallback = autoCloseOnLostFocus
? (hasFocus: boolean) => {
// If focus is ever lost, close the drop-down.
if (!hasFocus) {
hide();
}
}
: null;
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(
div,
autoCloseCallback,
);
}

return atOrigin;
Expand Down Expand Up @@ -693,7 +719,6 @@ export function hideWithoutAnimation() {
onHide();
onHide = null;
}
clearContent();
owner = null;

(common.getMainWorkspace() as WorkspaceSvg).markFocused();
Expand All @@ -702,6 +727,13 @@ export function hideWithoutAnimation() {
returnEphemeralFocus();
returnEphemeralFocus = null;
}

// Content must be cleared after returning ephemeral focus since otherwise it
// may force focus changes which could desynchronize the focus manager and
// make it think the user directed focus away from the drop-down div (which
// will then notify it to not restore focus back to any previously focused
// node).
clearContent();
}

/**
Expand Down
90 changes: 80 additions & 10 deletions core/focus_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
*/
export type ReturnEphemeralFocus = () => void;

/**
* Type declaration for an optional callback to observe when an element with
* ephemeral focus has its DOM focus changed before ephemeral focus is returned.
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void;

/**
* Represents an IFocusableTree that has been registered for focus management in
* FocusManager.
Expand Down Expand Up @@ -78,7 +86,10 @@ export class FocusManager {
private previouslyFocusedNode: IFocusableNode | null = null;
private registeredTrees: Array<TreeRegistration> = [];

private currentlyHoldsEphemeralFocus: boolean = false;
private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null;
private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null =
null;
private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false;
private lockFocusStateChanges: boolean = false;
private recentlyLostAllFocus: boolean = false;
private isUpdatingFocusedNode: boolean = false;
Expand Down Expand Up @@ -118,6 +129,21 @@ export class FocusManager {
} else {
this.defocusCurrentFocusedNode();
}

const ephemeralFocusElem = this.ephemerallyFocusedElement;
if (ephemeralFocusElem) {
const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus;
const hasFocus =
!!element &&
element instanceof Node &&
ephemeralFocusElem.contains(element);
if (hadFocus !== hasFocus) {
if (this.ephemeralDomFocusChangedCallback) {
this.ephemeralDomFocusChangedCallback(hasFocus);
}
this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus;
}
}
};

// Register root document focus listeners for tracking when focus leaves all
Expand Down Expand Up @@ -313,7 +339,7 @@ export class FocusManager {
*/
focusNode(focusableNode: IFocusableNode): void {
this.ensureManagerIsUnlocked();
const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus;
const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement;
if (mustRestoreUpdatingNode) {
// Disable state syncing from DOM events since possible calls to focus()
// below will loop a call back to focusNode().
Expand Down Expand Up @@ -395,7 +421,7 @@ export class FocusManager {
this.removeHighlight(nextTreeRoot);
}

if (!this.currentlyHoldsEphemeralFocus) {
if (!this.ephemerallyFocusedElement) {
// Only change the actively focused node if ephemeral state isn't held.
this.activelyFocusNode(nodeToFocus, prevTree ?? null);
}
Expand Down Expand Up @@ -423,24 +449,50 @@ export class FocusManager {
* the returned lambda is called. Additionally, only 1 ephemeral focus context
* can be active at any given time (attempting to activate more than one
* simultaneously will result in an error being thrown).
*
* Important details regarding the onFocusChangedInDom callback:
* - This method will be called initially with a value of 'true' indicating
* that the ephemeral element has been focused, so callers can rely on that,
* if needed, for initialization logic.
* - It's safe to end ephemeral focus in this callback (and is encouraged for
* callers that wish to automatically end ephemeral focus when the user
* directs focus outside of the element).
* - The element AND all of its descendants are tracked for focus. That means
* the callback will ONLY be called with a value of 'false' if focus
* completely leaves the DOM tree for the provided focusable element.
* - It's invalid to return focus on the very first call to the callback,
* however this is expected to be impossible, anyway, since this method
* won't return until after the first call to the callback (thus there will
* be no means to return ephemeral focus).
*
* @param focusableElement The element that should be focused until returned.
* @param onFocusChangedInDom An optional callback which will be notified
* whenever the provided element's focus changes before ephemeral focus is
* returned. See the details above for specifics.
* @returns A ReturnEphemeralFocus that must be called when ephemeral focus
* should end.
*/
takeEphemeralFocus(
focusableElement: HTMLElement | SVGElement,
onFocusChangedInDom: EphemeralFocusChangedInDom | null = null,
): ReturnEphemeralFocus {
this.ensureManagerIsUnlocked();
if (this.currentlyHoldsEphemeralFocus) {
if (this.ephemerallyFocusedElement) {
throw Error(
`Attempted to take ephemeral focus when it's already held, ` +
`with new element: ${focusableElement}.`,
);
}
this.currentlyHoldsEphemeralFocus = true;
this.ephemerallyFocusedElement = focusableElement;
this.ephemeralDomFocusChangedCallback = onFocusChangedInDom;

if (this.focusedNode) {
this.passivelyFocusNode(this.focusedNode, null);
}
focusableElement.focus();
this.ephemerallyFocusedElementCurrentlyHasFocus = true;

const focusedNodeAtStart = this.focusedNode;
let hasFinishedEphemeralFocus = false;
return () => {
if (hasFinishedEphemeralFocus) {
Expand All @@ -450,9 +502,22 @@ export class FocusManager {
);
}
hasFinishedEphemeralFocus = true;
this.currentlyHoldsEphemeralFocus = false;

if (this.focusedNode) {
this.ephemerallyFocusedElement = null;
this.ephemeralDomFocusChangedCallback = null;

const hadEphemeralFocusAtEnd =
this.ephemerallyFocusedElementCurrentlyHasFocus;
this.ephemerallyFocusedElementCurrentlyHasFocus = false;

// If the user forced away DOM focus during ephemeral focus, then
// determine whether focus should be restored back to a focusable node
// after ephemeral focus ends. Generally it shouldn't be, but in some
// cases (such as the user focusing an actual focusable node) it then
// should be.
const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode;
const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd;

if (this.focusedNode && shouldRestoreToNode) {
this.activelyFocusNode(this.focusedNode, null);

// Even though focus was restored, check if it's lost again. It's
Expand All @@ -470,6 +535,11 @@ export class FocusManager {
this.focusNode(capturedNode);
}
}, 0);
} else {
// If the ephemeral element lost focus then do not force it back since
// that likely will override the user's own attempt to move focus away
// from the ephemeral experience.
this.defocusCurrentFocusedNode();
}
};
}
Expand All @@ -478,7 +548,7 @@ export class FocusManager {
* @returns whether something is currently holding ephemeral focus
*/
ephemeralFocusTaken(): boolean {
return this.currentlyHoldsEphemeralFocus;
return !!this.ephemerallyFocusedElement;
}

/**
Expand Down Expand Up @@ -516,7 +586,7 @@ export class FocusManager {
// The current node will likely be defocused while ephemeral focus is held,
// but internal manager state shouldn't change since the node should be
// restored upon exiting ephemeral focus mode.
if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) {
if (this.focusedNode && !this.ephemerallyFocusedElement) {
this.passivelyFocusNode(this.focusedNode, null);
this.updateFocusedNode(null);
}
Expand Down
18 changes: 12 additions & 6 deletions core/widgetdiv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ export function hide() {

const div = containerDiv;
if (!div) return;

(common.getMainWorkspace() as WorkspaceSvg).markFocused();

if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}

// Content must be cleared after returning ephemeral focus since otherwise it
// may force focus changes which could desynchronize the focus manager and
// make it think the user directed focus away from the widget div (which will
// then notify it to not restore focus back to any previously focused node).
div.style.display = 'none';
div.style.left = '';
div.style.top = '';
Expand All @@ -163,12 +175,6 @@ export function hide() {
dom.removeClass(div, themeClassName);
themeClassName = '';
}
(common.getMainWorkspace() as WorkspaceSvg).markFocused();

if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}
}

/**
Expand Down
Loading