Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-keytips): add support for shortcuts #268

Closed
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add support for shortcuts",
"packageName": "@fluentui-contrib/react-keytips",
"email": "[email protected]",
"dependentChangeType": "patch"
}
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
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
Loading