Skip to content

Commit

Permalink
feat(react-keytips): add support for menu shortcuts
Browse files Browse the repository at this point in the history
  • Loading branch information
mainframev committed Dec 10, 2024
1 parent 9c84034 commit d9ac622
Show file tree
Hide file tree
Showing 15 changed files with 268 additions and 87 deletions.
7 changes: 5 additions & 2 deletions packages/react-keytips/src/components/Keytip/Keytip.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ export type KeytipSlots = {
content: NonNullable<Slot<'div'>>;
};

export type ExecuteKeytipEventHandler<E = HTMLElement> = EventHandler<
export type ExecuteKeytipEventHandler<E = HTMLElement | null> = EventHandler<
EventData<InvokeEvent, KeyboardEvent> & {
targetElement: E;
}
>;

export type ReturnKeytipEventHandler<E = HTMLElement> = EventHandler<
export type ReturnKeytipEventHandler<E = HTMLElement | null> = EventHandler<
EventData<InvokeEvent, KeyboardEvent> & {
targetElement: E;
}
Expand Down Expand Up @@ -59,8 +59,11 @@ export type KeytipProps = ComponentProps<KeytipSlots> & {
dynamic?: boolean;
};

/** @internal */
export type KeytipWithId = KeytipProps & {
uniqueId: string;
isShortcut?: boolean;
dependentKeys?: string[];
};

export type KeytipState = ComponentState<KeytipSlots> &
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@fluentui/react-components';
import { KeytipSlots, KeytipState } from './Keytip.types';
import { createSlideStyles } from '@fluentui/react-positioning';
import { SHOW_DELAY } from '../../constants';

export const keytipClassNames: SlotClassNames<KeytipSlots> = {
content: 'fui-Keytip__content',
Expand All @@ -30,7 +31,7 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackgroundInverted,
color: tokens.colorNeutralForegroundInverted,
boxShadow: tokens.shadow16,
...createSlideStyles(15),
...createSlideStyles(SHOW_DELAY),
},

visible: {
Expand Down
141 changes: 109 additions & 32 deletions packages/react-keytips/src/components/Keytips/useKeytips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ import * as React from 'react';
import {
getIntrinsicElementProps,
slot,
useIsomorphicLayoutEffect,
useFluent,
useTimeout,
} from '@fluentui/react-components';
import type { KeytipsProps, KeytipsState } from './Keytips.types';
import { useHotkeys, parseHotkey } from '../../hooks/useHotkeys';
import { EVENTS, VISUALLY_HIDDEN_STYLES, ACTIONS } from '../../constants';
import {
KTP_SEPARATOR,
EVENTS,
VISUALLY_HIDDEN_STYLES,
ACTIONS,
} from '../../constants';
import type { KeytipWithId } from '../Keytip';
import { Keytip } from '../Keytip';
import { useEventService } from '../../hooks/useEventService';
Expand All @@ -32,9 +39,10 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
invokeEvent = 'keydown',
startDelay = 0,
} = props;
const { subscribe, reset } = useEventService();
const { subscribe, reset, dispatch: dispatchEvent } = useEventService();
const [state, dispatch] = useKeytipsState();
const tree = useTree();
const [setShortcutTimeout, clearShortcutTimeout] = useTimeout();

const showKeytips = React.useCallback((ids: string[]) => {
dispatch({ type: ACTIONS.SET_VISIBLE_KEYTIPS, ids, targetDocument });
Expand All @@ -44,7 +52,6 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
(ev: KeyboardEvent) => {
if (!state.inKeytipMode) {
tree.currentKeytip.current = tree.root;

dispatch({ type: ACTIONS.ENTER_KEYTIP_MODE });
onEnterKeytipsMode?.(ev, { event: ev, type: 'keydown' });
showKeytips(tree.getChildren());
Expand Down Expand Up @@ -72,46 +79,56 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
const handleReturnSequence = React.useCallback(
(ev: KeyboardEvent) => {
if (!state.inKeytipMode) return;
const currentKeytip = tree.currentKeytip?.current;
const currentKeytip = tree.currentKeytip.current;
if (currentKeytip && currentKeytip.target) {
if (currentKeytip.target) {
currentKeytip?.onReturn?.(ev, {
event: ev,
type: invokeEvent,
targetElement: currentKeytip.target,
});
}
currentKeytip?.onReturn?.(ev, {
event: ev,
type: invokeEvent,
targetElement: currentKeytip.target,
});
}

dispatch({ type: ACTIONS.SET_SEQUENCE, value: '' });
tree.getBack();
showKeytips(tree.getChildren());
if (tree.currentKeytip.current === undefined) {
dispatch({ type: ACTIONS.EXIT_KEYTIP_MODE });
handleExitKeytipMode(ev);
}
},
[state.inKeytipMode]
);

const exitSequences = [
exitSequence,
'enter',
'space',
state.inKeytipMode ? 'tab' : '',
];

useHotkeys(
[
[startSequence, handleEnterKeytipMode, { delay: startDelay }],
[returnSequence, handleReturnSequence],
...[exitSequence, 'tab', 'enter', 'space'].map(
(key) => [key, handleExitKeytipMode] as Hotkey
),
...exitSequences.map((key) => [key, handleExitKeytipMode] as Hotkey),
],
invokeEvent
);

React.useEffect(() => {
useIsomorphicLayoutEffect(() => {
const handleKeytipAdded = (keytip: KeytipWithId) => {
tree.addNode(keytip);

dispatch({
type: ACTIONS.ADD_KEYTIP,
keytip,
});
if (keytip.isShortcut) {
dispatch({
type: ACTIONS.ADD_SHORTCUT,
shortcut: keytip,
});
} else {
dispatch({
type: ACTIONS.ADD_KEYTIP,
keytip,
});
}

if (tree.isCurrentKeytipParent(keytip)) {
showKeytips(tree.getChildren());
Expand All @@ -120,7 +137,12 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {

const handleKeytipRemoved = (keytip: KeytipWithId) => {
tree.removeNode(keytip.uniqueId);
dispatch({ type: ACTIONS.REMOVE_KEYTIP, id: keytip.uniqueId });

if (keytip.isShortcut) {
dispatch({ type: ACTIONS.REMOVE_SHORTCUT, id: keytip.uniqueId });
} else {
dispatch({ type: ACTIONS.REMOVE_KEYTIP, id: keytip.uniqueId });
}
};

const handleKeytipUpdated = (keytip: KeytipWithId) => {
Expand All @@ -130,15 +152,17 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
};

subscribe(EVENTS.KEYTIP_ADDED, handleKeytipAdded);
subscribe(EVENTS.SHORTCUT_ADDED, handleKeytipAdded);
subscribe(EVENTS.KEYTIP_UPDATED, handleKeytipUpdated);
subscribe(EVENTS.KEYTIP_REMOVED, handleKeytipRemoved);
subscribe(EVENTS.SHORTCUT_REMOVED, handleKeytipRemoved);

return () => {
reset();
};
}, []);
}, [state.inKeytipMode]);

React.useEffect(() => {
useIsomorphicLayoutEffect(() => {
const controller = new AbortController();
const { signal } = controller;

Expand All @@ -148,6 +172,7 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
}
};

targetDocument?.addEventListener('mousedown', handleDismiss, { signal });
targetDocument?.addEventListener('mouseup', handleDismiss, { signal });
targetDocument?.defaultView?.addEventListener('resize', handleDismiss, {
signal,
Expand All @@ -161,7 +186,8 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
};
}, [state.inKeytipMode, targetDocument, handleExitKeytipMode]);

const handleMatchingNode = React.useCallback(
// executes any normal keytip, except shortcuts
const handleKeytipExecution = React.useCallback(
(ev: KeyboardEvent, node: KeytipTreeNode) => {
tree.currentKeytip.current = node;

Expand All @@ -171,8 +197,9 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
type: invokeEvent,
targetElement: node.target,
});
}

dispatchEvent(EVENTS.KEYTIP_EXECUTED, node);
}
const currentChildren = tree.getChildren(node);
const shouldExitKeytipMode =
currentChildren.length === 0 && !node.dynamic;
Expand All @@ -189,6 +216,51 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
[handleExitKeytipMode]
);

// executes keytip that was triggered via shortcut
const handleShortcutExecution = React.useCallback(
async (ev: KeyboardEvent, node: KeytipTreeNode) => {
const { dependentKeys } = node;

if (!targetDocument) return;

const fullPath = [...dependentKeys, ...node.keySequences].reduce<
string[]
>((acc, key, idx) => {
if (idx === 0) acc.push(sequencesToID([key]));
else
acc.push(
acc[idx - 1] + KTP_SEPARATOR + key.split('').join(KTP_SEPARATOR)
);
return acc;
}, []);

// Sequentially execute each keytip in the path
for (const id of fullPath) {
clearShortcutTimeout();

await new Promise((resolve) => {
setShortcutTimeout(() => {
const currentNode = tree.getNode(id);

if (currentNode) {
currentNode.onExecute?.(ev, {
event: ev,
type: invokeEvent,
targetElement: currentNode.target,
});

tree.currentKeytip.current = currentNode;
dispatchEvent(EVENTS.KEYTIP_EXECUTED, currentNode);
}
// Proceed to the next keytip
resolve(currentNode);
}, 0);
});
}
},
[handleExitKeytipMode]
);

const handlePartiallyMatchedNodes = React.useCallback((sequence: string) => {
const partialNodes = tree.getPartiallyMatched(sequence);
if (partialNodes && partialNodes.length > 0) {
Expand All @@ -199,11 +271,10 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
}
}, []);

React.useEffect(() => {
useIsomorphicLayoutEffect(() => {
if (!targetDocument) return;

const handleInvokeEvent = (ev: KeyboardEvent) => {
ev.preventDefault();
ev.stopPropagation();

if (!state.inKeytipMode) return;
Expand All @@ -213,23 +284,29 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
const node = tree.getMatchingNode(currSeq);

if (node) {
handleMatchingNode(ev, node);
return;
if (node.isShortcut) {
handleShortcutExecution(ev, node);
} else {
handleKeytipExecution(ev, node);
}
} else {
// if we don't have a match, we have to check if the sequence is a partial match
handlePartiallyMatchedNodes(currSeq);
}
// if we don't have a match, we have to check if the sequence is a partial match
handlePartiallyMatchedNodes(currSeq);
};

targetDocument?.addEventListener(invokeEvent, handleInvokeEvent);
return () => {
targetDocument?.removeEventListener(invokeEvent, handleInvokeEvent);
clearShortcutTimeout();
};
}, [
state.inKeytipMode,
state.currentSequence,
handleExitKeytipMode,
handlePartiallyMatchedNodes,
handleMatchingNode,
handleShortcutExecution,
handleKeytipExecution,
]);

const visibleKeytips = Object.entries(state.keytips)
Expand Down
26 changes: 18 additions & 8 deletions packages/react-keytips/src/components/Keytips/useKeytipsState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ type KeytipsState = {
};

type KeytipsAction =
| { type: 'ENTER_KEYTIP_MODE' }
| { type: 'EXIT_KEYTIP_MODE' }
| { type: 'ADD_KEYTIP'; keytip: KeytipWithId }
| { type: 'UPDATE_KEYTIP'; keytip: KeytipWithId }
| { type: 'REMOVE_KEYTIP'; id: string }
| { type: 'SET_VISIBLE_KEYTIPS'; ids: string[]; targetDocument?: Document }
| { type: 'SET_SEQUENCE'; value: string };
| { type: typeof ACTIONS.ENTER_KEYTIP_MODE }
| { type: typeof ACTIONS.EXIT_KEYTIP_MODE }
| { type: typeof ACTIONS.ADD_KEYTIP; keytip: KeytipWithId }
| { type: typeof ACTIONS.UPDATE_KEYTIP; keytip: KeytipWithId }
| { type: typeof ACTIONS.REMOVE_KEYTIP; id: string }
| { type: typeof ACTIONS.ADD_SHORTCUT; shortcut: KeytipWithId }
| { type: typeof ACTIONS.REMOVE_SHORTCUT; id: string }
| {
type: typeof ACTIONS.SET_VISIBLE_KEYTIPS;
ids: string[];
targetDocument?: Document;
}
| { type: typeof ACTIONS.SET_SEQUENCE; value: string };

const stateReducer: React.Reducer<KeytipsState, KeytipsAction> = (
state,
Expand All @@ -29,7 +35,11 @@ const stateReducer: React.Reducer<KeytipsState, KeytipsAction> = (
return { ...state, inKeytipMode: true };
}
case ACTIONS.EXIT_KEYTIP_MODE: {
return { ...state, inKeytipMode: false, currentSequence: '' };
return {
...state,
inKeytipMode: false,
currentSequence: '',
};
}
case ACTIONS.ADD_KEYTIP: {
return {
Expand Down
9 changes: 8 additions & 1 deletion packages/react-keytips/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ export const KTP_SEPARATOR = '-';
export const DATAKTP_TARGET = 'data-ktp-target';
export const KTP_ROOT_ID = 'ktp';
export const KEYTIP_BORDER_RADIUS = 4;
export const SHOW_DELAY = 250;
export const SHOW_DELAY = 30;
export const INVISIBLE_KEYTIPS_ID = 'invisible-keytips-wrapper';

export const EVENTS = {
KEYTIP_ADDED: 'fui-keytip-added',
KEYTIP_REMOVED: 'fui-keytip-removed',
KEYTIP_UPDATED: 'fui-keytip-updated',
KEYTIP_EXECUTED: 'fui-keytip-executed',
SHORTCUT_ADDED: 'fui-shortcut-added',
SHORTCUT_REMOVED: 'fui-shortcut-removed',
SHORTCUT_EXECUTED: 'fui-shortcut-executed',
ENTER_KEYTIP_MODE: 'fui-enter-keytip-mode',
EXIT_KEYTIP_MODE: 'fui-exit-keytip-mode',
} as const;
Expand Down Expand Up @@ -47,7 +52,9 @@ export const ACTIONS = {
ENTER_KEYTIP_MODE: 'ENTER_KEYTIP_MODE',
EXIT_KEYTIP_MODE: 'EXIT_KEYTIP_MODE',
ADD_KEYTIP: 'ADD_KEYTIP',
ADD_SHORTCUT: 'ADD_SHORTCUT',
REMOVE_KEYTIP: 'REMOVE_KEYTIP',
REMOVE_SHORTCUT: 'REMOVE_SHORTCUT',
UPDATE_KEYTIP: 'UPDATE_KEYTIP',
SET_VISIBLE_KEYTIPS: 'SET_VISIBLE_KEYTIPS',
SET_SEQUENCE: 'SET_SEQUENCE',
Expand Down
Loading

0 comments on commit d9ac622

Please sign in to comment.