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 menu shorcuts #270

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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"
}
16 changes: 13 additions & 3 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 @@ -53,12 +53,22 @@ export type KeytipProps = ComponentProps<KeytipSlots> & {
*/
keySequences: string[];
/**
* Whether or not this keytip will have children keytips that are dynamically created (DOM is generated on * keytip activation).
* Whether or not this Keytip will have children keytips that are dynamically created (DOM is generated on * keytip activation).
* Common cases are a Tabs or Modal. Or if the keytip opens a menu.
*/
dynamic?: boolean;
/**
* Whether this Keytip can be accessed at the root level.
*/
isShortcut?: boolean;
/**
* Whether or not this Keytip belongs to a component that has a menu. Keytip mode will stay on when a menu is opened,
* even if the items in that menu have no keytips. If this is
*/
hasMenu?: boolean;
};

/** @internal */
export type KeytipWithId = KeytipProps & {
uniqueId: string;
};
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export type KeytipsSlots = {

export type InvokeEvent = 'keydown' | 'keyup';

type OnExitKeytipsModeData = EventData<'keydown', KeyboardEvent>;
type OnEnterKeytipsModeData = EventData<'keydown', KeyboardEvent>;
type OnExitKeytipsModeData = EventData<InvokeEvent, KeyboardEvent>;
type OnEnterKeytipsModeData = EventData<InvokeEvent, KeyboardEvent>;

export type KeytipsProps = ComponentProps<KeytipsSlots> &
Pick<PortalProps, 'mountNode'> & {
Expand Down
149 changes: 114 additions & 35 deletions packages/react-keytips/src/components/Keytips/useKeytips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import {
getIntrinsicElementProps,
slot,
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,
EXIT_KEYS,
EVENTS,
VISUALLY_HIDDEN_STYLES,
ACTIONS,
} from '../../constants';
import type { KeytipWithId } from '../Keytip';
import { Keytip } from '../Keytip';
import { useEventService } from '../../hooks/useEventService';
Expand All @@ -32,8 +39,9 @@ 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 [setShortcutTimeout, clearShortcutTimeout] = useTimeout();
const tree = useTree();

const showKeytips = React.useCallback((ids: string[]) => {
Expand All @@ -44,9 +52,8 @@ 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' });
onEnterKeytipsMode?.(ev, { event: ev, type: invokeEvent });
showKeytips(tree.getChildren());
} else {
dispatch({ type: ACTIONS.EXIT_KEYTIP_MODE });
Expand All @@ -62,7 +69,7 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
tree.currentKeytip.current = tree.root;
dispatch({ type: ACTIONS.SET_SEQUENCE, value: '' });
dispatch({ type: ACTIONS.EXIT_KEYTIP_MODE });
onExitKeytipsMode?.(ev, { event: ev, type: 'keydown' });
onExitKeytipsMode?.(ev, { event: ev, type: invokeEvent });
showKeytips([]);
}
},
Expand All @@ -72,34 +79,37 @@ 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,
...EXIT_KEYS,
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
);
Expand All @@ -113,13 +123,14 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
keytip,
});

if (tree.isCurrentKeytipParent(keytip)) {
if (state.inKeytipMode && tree.isCurrentKeytipParent(keytip)) {
showKeytips(tree.getChildren());
}
};

const handleKeytipRemoved = (keytip: KeytipWithId) => {
tree.removeNode(keytip.uniqueId);

dispatch({ type: ACTIONS.REMOVE_KEYTIP, id: keytip.uniqueId });
};

Expand All @@ -136,7 +147,7 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
return () => {
reset();
};
}, []);
}, [state.inKeytipMode]);

React.useEffect(() => {
const controller = new AbortController();
Expand All @@ -148,6 +159,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 +173,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,6 +184,8 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
type: invokeEvent,
targetElement: node.target,
});

dispatchEvent(EVENTS.KEYTIP_EXECUTED, node);
}

const currentChildren = tree.getChildren(node);
Expand All @@ -189,6 +204,64 @@ 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 { keySequences } = node;

if (!targetDocument) return;

const fullPath = 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;
}, []);

const nodeId = sequencesToID(keySequences);
const treeNode = tree.getNode(nodeId);

// if the node has menu, trigger overflow keytip and current keytip to show the menu
if (treeNode?.hasMenu) {
for (const id of fullPath) {
clearShortcutTimeout();
showKeytips([]);

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);
});
}
} else {
// if shortcut to a normal button call it's callback
treeNode?.onExecute?.(ev, {
event: ev,
type: invokeEvent,
targetElement: treeNode.target,
});

handleExitKeytipMode(ev);
}
},
[handleExitKeytipMode]
);

const handlePartiallyMatchedNodes = React.useCallback((sequence: string) => {
const partialNodes = tree.getPartiallyMatched(sequence);
if (partialNodes && partialNodes.length > 0) {
Expand All @@ -203,7 +276,6 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
if (!targetDocument) return;

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

if (!state.inKeytipMode) return;
Expand All @@ -213,23 +285,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 All @@ -238,15 +316,16 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => {
<Keytip key={keytipId} {...keytipProps} />
));

const hiddenKeytips = Object.values(state.keytips).map(({ keySequences }) => (
<span
key={sequencesToID(keySequences)}
id={sequencesToID(keySequences)}
style={VISUALLY_HIDDEN_STYLES}
>
{keySequences.join(', ')}
</span>
));
const hiddenKeytips = Object.values(state.keytips).map(
({ keySequences, uniqueId }) => {
const id = sequencesToID(keySequences);
return (
<span key={uniqueId} id={id} style={VISUALLY_HIDDEN_STYLES}>
{keySequences.join(', ')}
</span>
);
}
);

return {
components: {
Expand Down
Loading
Loading