diff --git a/change/@fluentui-contrib-react-keytips-8991e9af-7fa6-42dd-9ee0-ab9df03fa556.json b/change/@fluentui-contrib-react-keytips-8991e9af-7fa6-42dd-9ee0-ab9df03fa556.json new file mode 100644 index 00000000..e938bbf3 --- /dev/null +++ b/change/@fluentui-contrib-react-keytips-8991e9af-7fa6-42dd-9ee0-ab9df03fa556.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add support for shortcuts", + "packageName": "@fluentui-contrib/react-keytips", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-keytips/src/components/Keytip/Keytip.types.ts b/packages/react-keytips/src/components/Keytip/Keytip.types.ts index 2500e31d..902e1a55 100644 --- a/packages/react-keytips/src/components/Keytip/Keytip.types.ts +++ b/packages/react-keytips/src/components/Keytip/Keytip.types.ts @@ -17,13 +17,13 @@ export type KeytipSlots = { content: NonNullable>; }; -export type ExecuteKeytipEventHandler = EventHandler< +export type ExecuteKeytipEventHandler = EventHandler< EventData & { targetElement: E; } >; -export type ReturnKeytipEventHandler = EventHandler< +export type ReturnKeytipEventHandler = EventHandler< EventData & { targetElement: E; } @@ -53,12 +53,22 @@ export type KeytipProps = ComponentProps & { */ 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; }; diff --git a/packages/react-keytips/src/components/Keytip/useKeytipStyles.styles.ts b/packages/react-keytips/src/components/Keytip/useKeytipStyles.styles.ts index bdd7bffd..e52baef7 100644 --- a/packages/react-keytips/src/components/Keytip/useKeytipStyles.styles.ts +++ b/packages/react-keytips/src/components/Keytip/useKeytipStyles.styles.ts @@ -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 = { content: 'fui-Keytip__content', @@ -30,7 +31,7 @@ const useStyles = makeStyles({ backgroundColor: tokens.colorNeutralBackgroundInverted, color: tokens.colorNeutralForegroundInverted, boxShadow: tokens.shadow16, - ...createSlideStyles(15), + ...createSlideStyles(SHOW_DELAY), }, visible: { diff --git a/packages/react-keytips/src/components/Keytips/Keytips.types.ts b/packages/react-keytips/src/components/Keytips/Keytips.types.ts index d2422642..4defac80 100644 --- a/packages/react-keytips/src/components/Keytips/Keytips.types.ts +++ b/packages/react-keytips/src/components/Keytips/Keytips.types.ts @@ -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; +type OnEnterKeytipsModeData = EventData; export type KeytipsProps = ComponentProps & Pick & { diff --git a/packages/react-keytips/src/components/Keytips/useKeytips.tsx b/packages/react-keytips/src/components/Keytips/useKeytips.tsx index 8d480b5a..cb021c13 100644 --- a/packages/react-keytips/src/components/Keytips/useKeytips.tsx +++ b/packages/react-keytips/src/components/Keytips/useKeytips.tsx @@ -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'; @@ -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[]) => { @@ -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 }); @@ -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([]); } }, @@ -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 ); @@ -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 }); }; @@ -136,7 +147,7 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => { return () => { reset(); }; - }, []); + }, [state.inKeytipMode]); React.useEffect(() => { const controller = new AbortController(); @@ -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, @@ -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; @@ -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); @@ -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((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) { @@ -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; @@ -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) @@ -238,15 +316,16 @@ export const useKeytips_unstable = (props: KeytipsProps): KeytipsState => { )); - const hiddenKeytips = Object.values(state.keytips).map(({ keySequences }) => ( - - {keySequences.join(', ')} - - )); + const hiddenKeytips = Object.values(state.keytips).map( + ({ keySequences, uniqueId }) => { + const id = sequencesToID(keySequences); + return ( + + {keySequences.join(', ')} + + ); + } + ); return { components: { diff --git a/packages/react-keytips/src/components/Keytips/useKeytipsState.ts b/packages/react-keytips/src/components/Keytips/useKeytipsState.ts index f1b782e5..0a379962 100644 --- a/packages/react-keytips/src/components/Keytips/useKeytipsState.ts +++ b/packages/react-keytips/src/components/Keytips/useKeytipsState.ts @@ -3,7 +3,13 @@ import type { KeytipProps, KeytipWithId } from '../Keytip'; import { isTargetVisible, omit } from '../../utilities'; import { ACTIONS } from '../../constants'; -type Keytips = Record; +type Keytips = Record< + string, + KeytipProps & { + visibleInternal?: boolean; + uniqueId: string; + } +>; type KeytipsState = { inKeytipMode: boolean; @@ -12,13 +18,17 @@ 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.SET_VISIBLE_KEYTIPS; + ids: string[]; + targetDocument?: Document; + } + | { type: typeof ACTIONS.SET_SEQUENCE; value: string }; const stateReducer: React.Reducer = ( state, @@ -29,7 +39,11 @@ const stateReducer: React.Reducer = ( return { ...state, inKeytipMode: true }; } case ACTIONS.EXIT_KEYTIP_MODE: { - return { ...state, inKeytipMode: false, currentSequence: '' }; + return { + ...state, + inKeytipMode: false, + currentSequence: '', + }; } case ACTIONS.ADD_KEYTIP: { return { diff --git a/packages/react-keytips/src/constants.ts b/packages/react-keytips/src/constants.ts index c06f912f..295e5154 100644 --- a/packages/react-keytips/src/constants.ts +++ b/packages/react-keytips/src/constants.ts @@ -2,7 +2,6 @@ import { ArrowLeft, Enter, Space, - Tab, ArrowUp, ArrowDown, ArrowRight, @@ -13,12 +12,15 @@ 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_EXECUTED: 'fui-keytip-executed', ENTER_KEYTIP_MODE: 'fui-enter-keytip-mode', EXIT_KEYTIP_MODE: 'fui-exit-keytip-mode', } as const; @@ -34,7 +36,6 @@ export const VISUALLY_HIDDEN_STYLES = { } as React.CSSProperties; export const EXIT_KEYS = [ - Tab, Enter, Space, ArrowUp, diff --git a/packages/react-keytips/src/docs/MIGRATION.md b/packages/react-keytips/src/docs/MIGRATION.md index a9ea4495..f79cd1e6 100644 --- a/packages/react-keytips/src/docs/MIGRATION.md +++ b/packages/react-keytips/src/docs/MIGRATION.md @@ -20,8 +20,10 @@ This Migration guide is a work in progress and is not yet ready for use. - `useKeytipRef`: - `offset` - Changed. Instead use [positioning.offset](https://react.fluentui.dev/?path=/docs/concepts-developer-positioning-components--docs#offset-value). - `styles` - Not supported. - - `theme` - Not supported. + - `theme` - Not supported. `Keytip` theme depends on the values passed to `css` variables. - `disabled` - Not supported. `Keytip` won't appear for disabled target. `callOutProps` -> Not supported. Instead there are multiple props available, that has to be used individually: `positioning`, `visible`, `content`. - - `hasDynamicChildren` and `hasMenu` - merged into `dynamic` prop. If `Keytip` triggers dynamic content: Menu, Modal, Tabs or any other item that + - `hasDynamicChildren` became `dynamic` prop. If `Keytip` triggers dynamic content: Menu, Modal, Tabs or any other item that that has keytips, set `dynamic` to `true`. + - `overflowSetSequence` - Not supported. + - `hasOverflowSubMenu` -> Changed. Instead use `hasMenu`. diff --git a/packages/react-keytips/src/docs/Spec.md b/packages/react-keytips/src/docs/Spec.md index e0ff1761..c2b0c80f 100644 --- a/packages/react-keytips/src/docs/Spec.md +++ b/packages/react-keytips/src/docs/Spec.md @@ -135,6 +135,8 @@ The keytip is positioned below and centered to the target element by default. | `dynamic` | `boolean` | | Whether or not this keytip will have dynamic content: children keytips that are dynamically created (DOM is generated on keytip activation), Menu, Tabs or Modal. | | `visible` | `boolean` | `false` | Control the Keytip's visibility programmatically. | | `content` | `string` | | The text content of the Keytip. | +| `isShortcut` | `boolean` | `false` | Registers keytip as a shortcut, will try to invoke the full sequence until it will reach the target keytip. | +| `hasMenu` | `boolean` | `false` | 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. | ## Structure diff --git a/packages/react-keytips/src/hooks/useEventService.ts b/packages/react-keytips/src/hooks/useEventService.ts index b61910d7..2a903a7f 100644 --- a/packages/react-keytips/src/hooks/useEventService.ts +++ b/packages/react-keytips/src/hooks/useEventService.ts @@ -1,7 +1,8 @@ -import { useCallback, useRef, useEffect } from 'react'; +import * as React from 'react'; import { useFluent } from '@fluentui/react-components'; import { EVENTS } from '../constants'; import type { KeytipWithId } from '../components/Keytip'; +import type { KeytipTreeNode } from '../hooks/useTree'; type EventType = (typeof EVENTS)[keyof typeof EVENTS]; @@ -11,6 +12,8 @@ type PayloadDefinition = { [EVENTS.KEYTIP_UPDATED]: KeytipWithId; [EVENTS.KEYTIP_ADDED]: KeytipWithId; [EVENTS.KEYTIP_REMOVED]: KeytipWithId; + [EVENTS.KEYTIP_EXECUTED]: KeytipTreeNode; + [EVENTS.SHORTCUT_EXECUTED]: KeytipTreeNode; }; function isCustomEvent(event: Event): event is CustomEvent { @@ -30,9 +33,11 @@ function createEventHandler( export function useEventService() { const { targetDocument } = useFluent(); - const controller = useRef(new AbortController()); + const controller = React.useRef( + new AbortController() + ); - const dispatch = useCallback( + const dispatch = React.useCallback( (eventName: T, payload?: PayloadDefinition[T]) => { const event = payload ? new CustomEvent(eventName, { detail: payload }) @@ -42,7 +47,7 @@ export function useEventService() { [targetDocument] ); - const subscribe = useCallback( + const subscribe = React.useCallback( ( event: T, handler: (payload: PayloadDefinition[T]) => void @@ -64,14 +69,14 @@ export function useEventService() { [targetDocument] ); - const reset = useCallback(() => { + const reset = React.useCallback(() => { if (controller.current) { controller.current.abort(); controller.current = null; } }, []); - useEffect(() => { + React.useEffect(() => { return () => { reset(); }; diff --git a/packages/react-keytips/src/hooks/useHotkeys.ts b/packages/react-keytips/src/hooks/useHotkeys.ts index e6b31533..bc047c23 100644 --- a/packages/react-keytips/src/hooks/useHotkeys.ts +++ b/packages/react-keytips/src/hooks/useHotkeys.ts @@ -1,10 +1,5 @@ import * as React from 'react'; - -import { - useIsomorphicLayoutEffect, - useFluent, - useTimeout, -} from '@fluentui/react-components'; +import { useFluent, useTimeout } from '@fluentui/react-components'; import { KeytipsProps } from '../components/Keytips/Keytips.types'; type Options = { @@ -73,7 +68,7 @@ export const useHotkeys = ( const doc = target ?? targetDocument; const activeKeys = React.useRef>(new Set()); - useIsomorphicLayoutEffect(() => { + React.useEffect(() => { const handleInvokeEvent = (ev: KeyboardEvent) => { hotkeys.forEach( ([hotkey, handler, options = { preventDefault: true }]) => { diff --git a/packages/react-keytips/src/hooks/useKeytipRef.ts b/packages/react-keytips/src/hooks/useKeytipRef.ts index eb8b8231..2601e7b1 100644 --- a/packages/react-keytips/src/hooks/useKeytipRef.ts +++ b/packages/react-keytips/src/hooks/useKeytipRef.ts @@ -11,27 +11,31 @@ const isEqualArray = (a: string[], b: string[]) => { export const useKeytipRef = < T extends HTMLElement = HTMLButtonElement | HTMLAnchorElement ->( - keytip: Omit -): React.Dispatch> => { - const uniqueId = React.useId(); +>({ + ...keytip +}: KeytipProps): React.Dispatch> => { const [node, setNode] = React.useState(null); const { dispatch } = useEventService(); + const uniqueId = React.useId(); + const keySequences = keytip.keySequences.map((k) => k.toLowerCase()); + const id = sequencesToID(keySequences); + const ktp = React.useMemo( () => ({ ...keytip, + id, uniqueId, - id: sequencesToID(keytip.keySequences), + keySequences, positioning: { target: node, ...keytip.positioning, }, }), - [keytip, uniqueId, node] + [keytip, node] ); - const prevKeytip = usePrevious(ktp); + const prevKeytip = usePrevious(keytip); // this will run on every render, in order to update the keytip if the keySequences change React.useEffect(() => { diff --git a/packages/react-keytips/src/hooks/useTree.test.ts b/packages/react-keytips/src/hooks/useTree.test.ts index c713eb42..e08ffd17 100644 --- a/packages/react-keytips/src/hooks/useTree.test.ts +++ b/packages/react-keytips/src/hooks/useTree.test.ts @@ -117,7 +117,9 @@ describe('useTree', () => { expect(result.current.root).toEqual({ id: KTP_ROOT_ID, children: new Set(), + isShortcut: false, target: null, + hasMenu: false, parent: '', keySequences: [], uniqueId: KTP_ROOT_ID, diff --git a/packages/react-keytips/src/hooks/useTree.ts b/packages/react-keytips/src/hooks/useTree.ts index 249350ab..cdb6c28f 100644 --- a/packages/react-keytips/src/hooks/useTree.ts +++ b/packages/react-keytips/src/hooks/useTree.ts @@ -1,12 +1,17 @@ import type { KeytipProps, KeytipWithId } from '../components/Keytip'; import * as React from 'react'; -import { sequencesToID, createNode } from '../utilities/index'; +import { sequencesToID, createNode, createAliasNode } from '../utilities/index'; import { KTP_ROOT_ID } from '../constants'; import { useFluent } from '@fluentui/react-components'; export type KeytipTreeNode = Pick< KeytipProps, - 'keySequences' | 'onExecute' | 'onReturn' | 'dynamic' + | 'keySequences' + | 'onExecute' + | 'onReturn' + | 'dynamic' + | 'hasMenu' + | 'isShortcut' > & { id: string; uniqueId: string; @@ -27,8 +32,10 @@ export function useTree() { id: KTP_ROOT_ID, uniqueId: KTP_ROOT_ID, children: new Set(), + isShortcut: false, target: null, parent: '', + hasMenu: false, keySequences: [], }), [] @@ -40,9 +47,17 @@ export function useTree() { const currentKeytip = React.useRef(); - const addNode = React.useCallback((keytip: KeytipWithId) => { + const addNode = React.useCallback((newNode: KeytipWithId) => { + // if newNode has isShortcut:true, create alias node under the root + if (newNode.isShortcut) { + const alias = createAliasNode(newNode); + nodeMap.current.set(alias.uniqueId, alias); + const root = nodeMap.current.get(KTP_ROOT_ID); + root?.children.add(alias.uniqueId); + } + const node = createNode({ - ...keytip, + ...newNode, nodeMap: nodeMap.current, }); @@ -102,6 +117,7 @@ export function useTree() { const getMatchingNode = React.useCallback( (sequence: string) => { const { current } = currentKeytip; + if (!current) { return undefined; } @@ -114,9 +130,13 @@ export function useTree() { return undefined; } - currentKeytip.current = matchingNodes[0]; + const matched = matchingNodes[0]; + + if (!matched) return undefined; + + currentKeytip.current = matched; - return matchingNodes[0]; + return matched; }, [targetDocument] ); @@ -155,15 +175,24 @@ export function useTree() { const isCurrentKeytipParent = React.useCallback((keytip: KeytipProps) => { if (!currentKeytip.current) return false; - const fullSequence = keytip.keySequences.slice(0, -1); + const sequence = keytip.keySequences.slice(0, -1) ?? []; const parentID = - fullSequence.length === 0 ? KTP_ROOT_ID : sequencesToID(fullSequence); + sequence.length === 0 ? KTP_ROOT_ID : sequencesToID(sequence); return currentKeytip.current.id === parentID; }, []); + const getNode = (id: string) => { + for (const node of nodeMap.current.values()) { + if (node.id === id) { + return node; + } + } + }; + return { nodeMap, addNode, + getNode, updateNode, root, currentKeytip, diff --git a/packages/react-keytips/src/index.ts b/packages/react-keytips/src/index.ts index 73c24b51..d495f4b9 100644 --- a/packages/react-keytips/src/index.ts +++ b/packages/react-keytips/src/index.ts @@ -20,6 +20,11 @@ export { useKeytips_unstable, } from './components/Keytips'; +export { EVENTS } from './constants'; + +export { sequencesToID } from './utilities'; + export type { KeytipsProps, KeytipsSlots, KeytipsState } from './Keytips'; export { useKeytipRef } from './hooks/useKeytipRef'; +export { useEventService } from './hooks/useEventService'; diff --git a/packages/react-keytips/src/utilities/createAliasNode.ts b/packages/react-keytips/src/utilities/createAliasNode.ts new file mode 100644 index 00000000..76361d5f --- /dev/null +++ b/packages/react-keytips/src/utilities/createAliasNode.ts @@ -0,0 +1,29 @@ +import { sequencesToID } from './sequencesToID'; +import { KeytipTreeNode } from '../hooks/useTree'; +import { KeytipWithId } from '../components/Keytip/Keytip.types'; +import { KTP_ROOT_ID } from '../constants'; + +export const createAliasNode = ({ + keySequences, + onExecute, + uniqueId, + onReturn, +}: KeytipWithId): KeytipTreeNode => { + const id = sequencesToID(keySequences.slice(-1)); + + const node: KeytipTreeNode = { + target: null, + id, + uniqueId: `${uniqueId}-alias`, + isShortcut: true, + parent: KTP_ROOT_ID, + children: new Set(), + onExecute, + onReturn, + hasMenu: false, + dynamic: false, + keySequences: keySequences, + }; + + return node; +}; diff --git a/packages/react-keytips/src/utilities/createNode.ts b/packages/react-keytips/src/utilities/createNode.ts index 279d419d..1448d80f 100644 --- a/packages/react-keytips/src/utilities/createNode.ts +++ b/packages/react-keytips/src/utilities/createNode.ts @@ -4,20 +4,20 @@ import { KeytipWithId } from '../components/Keytip/Keytip.types'; export const createNode = ({ keySequences, - uniqueId, onExecute, onReturn, dynamic, nodeMap, + hasMenu, positioning, + uniqueId, }: KeytipWithId & { nodeMap: Map; }): KeytipTreeNode => { const id = sequencesToID(keySequences); + const parent = - keySequences.length > 0 - ? sequencesToID(keySequences.slice(0, keySequences.length - 1)) - : ''; + keySequences.length > 0 ? sequencesToID(keySequences.slice(0, -1)) : ''; const children = new Set(); @@ -34,10 +34,12 @@ export const createNode = ({ target: positioning?.target as HTMLElement, parent, children, - keySequences: keySequences.map((key) => key.toLowerCase()), + isShortcut: false, + hasMenu, + dynamic, + keySequences, onExecute, onReturn, - dynamic, }; return node; diff --git a/packages/react-keytips/src/utilities/index.ts b/packages/react-keytips/src/utilities/index.ts index 308ef09f..8d2bb273 100644 --- a/packages/react-keytips/src/utilities/index.ts +++ b/packages/react-keytips/src/utilities/index.ts @@ -1,5 +1,6 @@ export * from './sequencesToID'; export * from './createNode'; +export * from './createAliasNode'; export * from './isTargetVisible'; export * from './isDisabled'; export * from './omit'; diff --git a/packages/react-keytips/stories/Default.stories.tsx b/packages/react-keytips/stories/Default.stories.tsx index c69707c0..2e058c6b 100644 --- a/packages/react-keytips/stories/Default.stories.tsx +++ b/packages/react-keytips/stories/Default.stories.tsx @@ -40,25 +40,27 @@ const useStyles = makeStyles({ const onExecute: ExecuteKeytipEventHandler = (_, { targetElement }) => { if (targetElement) { + console.info(targetElement.getAttribute('aria-3describedby')); + targetElement.focus(); targetElement.click(); } }; const SplitButtonComponent = () => { const splitButton = useKeytipRef({ - keySequences: ['b3'], - content: 'B3', + keySequences: ['1d'], + content: '1D', onExecute, }); const menuItemA = useKeytipRef({ - keySequences: ['b3', '1'], + keySequences: ['1d', '1'], content: '1', onExecute: () => alert('Item A'), }); const menuItemB = useKeytipRef({ - keySequences: ['b3', '2'], + keySequences: ['1d', '2'], content: '2', onExecute: () => alert('Item B'), }); @@ -90,19 +92,19 @@ const SplitButtonComponent = () => { const MenuButtonComponent = () => { const menuRef = useKeytipRef({ - keySequences: ['2a'], - content: '2A', + keySequences: ['1b'], + content: '1B', onExecute, }); const firstMenuItemRef = useKeytipRef({ - keySequences: ['2a', 'e'], + keySequences: ['1b', 'e'], content: 'E', onExecute, }); const secondMenuItemRef = useKeytipRef({ - keySequences: ['2a', 'f'], + keySequences: ['1b', 'f'], content: 'F', onExecute, }); @@ -127,27 +129,27 @@ export const DefaultStory = () => { const classes = useStyles(); const disabledButton = useKeytipRef({ - keySequences: ['b0'], - content: 'B0', + keySequences: ['1a'], + content: '1A', onExecute, }); const normalButton = useKeytipRef({ - keySequences: ['b1'], - content: 'B1', + keySequences: ['1c'], + content: '1C', onExecute, }); const compoundButton = useKeytipRef({ - keySequences: ['b2'], - content: 'B2', + keySequences: ['1e'], + content: '1E', onExecute, }); const offsetButton = useKeytipRef({ - keySequences: ['b4'], + keySequences: ['ee'], positioning: { offset: { crossAxis: -50, mainAxis: 5 } }, - content: 'B4', + content: 'EE', onExecute, }); diff --git a/packages/react-keytips/stories/OverflowMenu.md b/packages/react-keytips/stories/OverflowMenu.md index adc5658a..1595b5eb 100644 --- a/packages/react-keytips/stories/OverflowMenu.md +++ b/packages/react-keytips/stories/OverflowMenu.md @@ -1,14 +1,23 @@ -Keytips with Overflow require `dynamic` prop to be passed with `useKeytipRef`. You can also register -a `persisted` keytip, that can be accessed from the top level as a shortcut. A shortcut to a normal Button -will trigger the Button, shortcut to a MenuButton will open a menu. In this example, firing `B` and `C` -will show this functionality. +Keytips with `Overflow` and `Menu` components offer special options: + +`isShortcut` - a Keytip can be a shortcut, that can be accessed from the top overflow level. A shortcut to a normal Button will trigger it immediately, +if it's attached to a Menu, it will open a Menu, even if Keytip does not have a child Keytip to show. + +In this example, firing `T`, `Y` and `R` will show this functionality. ```tsx const subMenuRef = useKeytipRef({ - keySequences: ['a', 'b'], - content: 'B', - dynamic: true, - persited: true, + keySequences: ['y'], + content: 'Y', + hasMenu: true, + isShortcut: true, + onExecute, +}); + +const subMenuRefItem = useKeytipRef({ + keySequences: ['t'], + content: 'T', + isShortcut: true, onExecute, }); ``` diff --git a/packages/react-keytips/stories/OverflowMenu.stories.tsx b/packages/react-keytips/stories/OverflowMenu.stories.tsx index 9fc86a04..c8183810 100644 --- a/packages/react-keytips/stories/OverflowMenu.stories.tsx +++ b/packages/react-keytips/stories/OverflowMenu.stories.tsx @@ -2,9 +2,11 @@ import * as React from 'react'; import { ExecuteKeytipEventHandler, useKeytipRef, + KeytipProps, } from '@fluentui-contrib/react-keytips'; import { makeStyles, + mergeClasses, Button, Menu, MenuTrigger, @@ -12,14 +14,12 @@ import { MenuList, MenuItem, MenuButton, - tokens, - mergeClasses, Overflow, - OverflowItemProps, OverflowItem, - useIsOverflowItemVisible, useOverflowMenu, useMergedRefs, + tokens, + useIsOverflowItemVisible, } from '@fluentui/react-components'; import description from './OverflowMenu.md'; @@ -33,7 +33,7 @@ const useStyles = makeStyles({ resizableArea: { minWidth: '200px', - maxWidth: '800px', + maxWidth: '500px', border: `2px solid ${tokens.colorBrandBackground}`, padding: '20px 10px 10px 10px', position: 'relative', @@ -55,80 +55,105 @@ const useStyles = makeStyles({ }, }); -const onExecute: ExecuteKeytipEventHandler = (_, el) => { - el.targetElement.click(); +const onExecute: ExecuteKeytipEventHandler = (_, { targetElement }) => { + if (targetElement) { + console.info(targetElement); + targetElement.focus(); + targetElement.click(); + } }; -const SubMenuSecond = () => { - const subMenuRef = useKeytipRef({ - keySequences: ['a', 'b', 'c'], - content: 'C', - dynamic: true, - onExecute, - }); +const menuItems = { + Q: { id: '1', keySequences: ['q'], content: 'Q', onExecute }, + W: { id: '2', keySequences: ['w'], content: 'W', onExecute }, + E: { id: '3', keySequences: ['e'], content: 'E', onExecute }, + R: { id: '4', keySequences: ['r'], content: 'R', onExecute }, + T: { id: '5', keySequences: ['t'], content: 'T', onExecute }, +}; + +const OverflowItemWrapper = React.forwardRef< + HTMLDivElement, + { keytipProps: KeytipProps & { id: string } } +>(({ keytipProps }, ref) => { + const keytipRef = useKeytipRef(keytipProps); + + const mergedRefs = useMergedRefs(ref, keytipRef); return ( - - - Sub Menu - + + + + ); +}); - - - 11 - 12 - 13 - - - +const OverflowMenuItemWrapper = React.forwardRef< + HTMLDivElement, + { keytipProps: KeytipProps & { id: string } } +>(({ keytipProps }, ref) => { + const isVisible = useIsOverflowItemVisible(keytipProps.id); + false; + + const keytipRef = useKeytipRef({ + ...keytipProps, + isShortcut: !isVisible, + keySequences: !isVisible + ? ['d', ...keytipProps.keySequences] + : keytipProps.keySequences, + }); + + const mergedRefs = useMergedRefs(ref, keytipRef); + + if (isVisible) { + return null; + } + + return ( + + Item {keytipProps.id} + ); -}; +}); -const SubMenu = () => { - const subMenuRef = useKeytipRef({ - keySequences: ['a', 'b'], - content: 'B', - dynamic: true, +const SubMenu = React.forwardRef((_, ref) => { + const menuRefItem = useKeytipRef({ + keySequences: ['d', 'y', 'h'], + content: 'H', onExecute, }); return ( - Sub Menu + Overflow Item - 8 - 9 - 10 - + Overflow SubMenu Item ); -}; - -const OverflowMenuItem = (props: Pick) => { - const { id } = props; - const isVisible = useIsOverflowItemVisible(id); - - if (isVisible) { - return null; - } +}); - return Item {id}; -}; +const OverflowMenu = ({ + menuItems, +}: { + menuItems: Record; +}) => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); -const OverflowMenu = ({ itemIds }: { itemIds: string[] }) => { - const { ref, overflowCount, isOverflowing } = - useOverflowMenu(); + const menuRef = useKeytipRef({ + keySequences: ['d'], + content: 'D', + onExecute, + }); - const menuRef = useKeytipRef({ - keySequences: ['a'], - content: 'A', - dynamic: true, + const subMenuRef = useKeytipRef({ + keySequences: ['d', 'y'], + content: 'Y', + isShortcut: true, + hasMenu: true, onExecute, }); @@ -146,10 +171,13 @@ const OverflowMenu = ({ itemIds }: { itemIds: string[] }) => { - {itemIds.map((i) => { - return ; - })} - + {Object.values(menuItems).map((keytipProps) => ( + + ))} + @@ -159,21 +187,15 @@ const OverflowMenu = ({ itemIds }: { itemIds: string[] }) => { export const OverflowStory = () => { const styles = useStyles(); - const itemIds = new Array(8).fill(0).map((_, i) => i.toString()); - return ( - <> - -
- {itemIds.map((i) => ( - - - - ))} - -
-
- + +
+ {Object.entries(menuItems).map(([, props]) => ( + + ))} + +
+
); }; diff --git a/packages/react-keytips/stories/WithTabs.stories.tsx b/packages/react-keytips/stories/WithTabs.stories.tsx index fdbbeca2..0fbfa8fd 100644 --- a/packages/react-keytips/stories/WithTabs.stories.tsx +++ b/packages/react-keytips/stories/WithTabs.stories.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { ExecuteKeytipEventHandler, useKeytipRef, - KeytipsProps, } from '@fluentui-contrib/react-keytips'; import { makeStyles, @@ -53,50 +52,50 @@ export const WithTabsStory = () => { }; const refFirstTab = useKeytipRef({ - keySequences: ['a'], - content: 'A', + keySequences: ['b1'], + content: 'B1', onExecute: btnExecute, }); const refSecondTab = useKeytipRef({ - keySequences: ['b'], - content: 'B', + keySequences: ['b2'], + content: 'B2', onExecute: btnExecute, }); const refThirdTab = useKeytipRef({ - keySequences: ['c'], - content: 'C', + keySequences: ['b3'], + content: 'B3', onExecute: btnExecute, }); const checkBoxRef = useKeytipRef({ - keySequences: ['a', '1'], + keySequences: ['b1', '1'], content: '1', onExecute: btnExecute, }); const switchRef = useKeytipRef({ - keySequences: ['a', '2'], + keySequences: ['b1', '2'], content: '2', onExecute: btnExecute, }); const linkRef = useKeytipRef({ - keySequences: ['a', '3'], + keySequences: ['b1', '3'], content: '3', onExecute: btnExecute, }); const btnRef = useKeytipRef({ - keySequences: ['b', '1'], - content: 'B1', + keySequences: ['b2', '1'], + content: '1', onExecute: btnExecute, }); const btnRefSecond = useKeytipRef({ - keySequences: ['c', '1'], - content: 'C1', + keySequences: ['b3', '1'], + content: '1', onExecute: btnExecute, });