initial commit
This commit is contained in:
130
fluxer_app/src/stores/ContextMenuStore.tsx
Normal file
130
fluxer_app/src/stores/ContextMenuStore.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import KeyboardModeStore from './KeyboardModeStore';
|
||||
|
||||
const logger = new Logger('ContextMenuStore');
|
||||
|
||||
export interface FocusableContextMenuTarget {
|
||||
tagName: string;
|
||||
isConnected: boolean;
|
||||
focus: (options?: FocusOptions) => void;
|
||||
addEventListener: HTMLElement['addEventListener'];
|
||||
removeEventListener: HTMLElement['removeEventListener'];
|
||||
}
|
||||
|
||||
export type ContextMenuTargetElement = HTMLElement | FocusableContextMenuTarget;
|
||||
|
||||
export const isContextMenuNodeTarget = (target: ContextMenuTargetElement | null | undefined): target is HTMLElement => {
|
||||
if (!target || typeof Node === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return target instanceof HTMLElement;
|
||||
};
|
||||
|
||||
export interface ContextMenuTarget {
|
||||
x: number;
|
||||
y: number;
|
||||
target: ContextMenuTargetElement;
|
||||
}
|
||||
|
||||
export interface ContextMenuConfig {
|
||||
onClose?: () => void;
|
||||
noBlurEvent?: boolean;
|
||||
returnFocus?: boolean;
|
||||
returnFocusTarget?: ContextMenuTargetElement | null;
|
||||
align?: 'top-left' | 'top-right';
|
||||
}
|
||||
|
||||
export interface ContextMenu {
|
||||
id: string;
|
||||
target: ContextMenuTarget;
|
||||
render: (props: {onClose: () => void}) => React.ReactNode;
|
||||
config?: ContextMenuConfig;
|
||||
}
|
||||
|
||||
export interface FocusRestoreState {
|
||||
target: ContextMenuTargetElement | null;
|
||||
keyboardModeEnabled: boolean;
|
||||
}
|
||||
|
||||
class ContextMenuStore {
|
||||
contextMenu: ContextMenu | null = null;
|
||||
private focusRestoreState: FocusRestoreState | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
}
|
||||
|
||||
open(contextMenu: ContextMenu): void {
|
||||
logger.debug(`Opening context menu: ${contextMenu.id}`);
|
||||
this.contextMenu = contextMenu;
|
||||
const requestedTarget = contextMenu.config?.returnFocusTarget ?? contextMenu.target.target;
|
||||
this.focusRestoreState = {
|
||||
target: requestedTarget ?? null,
|
||||
keyboardModeEnabled: KeyboardModeStore.keyboardModeEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.contextMenu) {
|
||||
logger.debug(`Closing context menu: ${this.contextMenu.id}`);
|
||||
const {config, target} = this.contextMenu;
|
||||
const shouldReturnFocus = config?.returnFocus ?? true;
|
||||
const fallbackTarget = target.target;
|
||||
const restoreState = shouldReturnFocus ? this.focusRestoreState : null;
|
||||
const focusTarget = config?.returnFocusTarget ?? restoreState?.target ?? fallbackTarget ?? null;
|
||||
const resumeKeyboardMode = Boolean(restoreState?.keyboardModeEnabled);
|
||||
config?.onClose?.();
|
||||
this.contextMenu = null;
|
||||
this.focusRestoreState = null;
|
||||
if (shouldReturnFocus) {
|
||||
this.restoreFocus(focusTarget, resumeKeyboardMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private restoreFocus(target: ContextMenuTargetElement | null, resumeKeyboardMode: boolean): void {
|
||||
logger.debug(
|
||||
`ContextMenuStore.restoreFocus target=${target ? target.tagName : 'null'} resumeKeyboardMode=${resumeKeyboardMode}`,
|
||||
);
|
||||
if (!target) return;
|
||||
queueMicrotask(() => {
|
||||
if (!target.isConnected) {
|
||||
logger.debug('ContextMenuStore.restoreFocus aborted: target disconnected');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
target.focus({preventScroll: true});
|
||||
logger.debug('ContextMenuStore.restoreFocus applied focus to target');
|
||||
} catch (error) {
|
||||
logger.error('ContextMenuStore.restoreFocus failed to focus target', error as Error);
|
||||
return;
|
||||
}
|
||||
if (resumeKeyboardMode) {
|
||||
logger.debug('ContextMenuStore.restoreFocus re-entering keyboard mode');
|
||||
KeyboardModeStore.enterKeyboardMode(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContextMenuStore();
|
||||
Reference in New Issue
Block a user