diff --git a/common/changes/@uifabric/experiments/features-keytips2_2018-02-21-21-49.json b/common/changes/@uifabric/experiments/features-keytips2_2018-02-21-21-49.json new file mode 100644 index 0000000000000..b923866b6efd0 --- /dev/null +++ b/common/changes/@uifabric/experiments/features-keytips2_2018-02-21-21-49.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@uifabric/experiments", + "comment": "Add initial set of Keytip work", + "type": "patch" + } + ], + "packageName": "@uifabric/experiments", + "email": "keyou@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/@uifabric/utilities/features-keytips2_2018-02-21-21-49.json b/common/changes/@uifabric/utilities/features-keytips2_2018-02-21-21-49.json new file mode 100644 index 0000000000000..2dbeac11bd2a3 --- /dev/null +++ b/common/changes/@uifabric/utilities/features-keytips2_2018-02-21-21-49.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@uifabric/utilities", + "comment": "Update KeyCodes enum to include all key codes", + "type": "minor" + } + ], + "packageName": "@uifabric/utilities", + "email": "keyou@microsoft.com" +} \ No newline at end of file diff --git a/package.json b/package.json index 728bc8ebcc2d9..85b7f65ee7098 100644 --- a/package.json +++ b/package.json @@ -30,4 +30,4 @@ "devDependencies": { "@microsoft/rush": "4.1.0" } -} \ No newline at end of file +} diff --git a/packages/experiments/src/Keytip.ts b/packages/experiments/src/Keytip.ts new file mode 100644 index 0000000000000..0e3d31cfbafad --- /dev/null +++ b/packages/experiments/src/Keytip.ts @@ -0,0 +1 @@ +export * from './components/Keytip/index'; \ No newline at end of file diff --git a/packages/experiments/src/KeytipLayer.ts b/packages/experiments/src/KeytipLayer.ts new file mode 100644 index 0000000000000..39e83ed745652 --- /dev/null +++ b/packages/experiments/src/KeytipLayer.ts @@ -0,0 +1 @@ +export * from './components/KeytipLayer/index'; \ No newline at end of file diff --git a/packages/experiments/src/Utilities.ts b/packages/experiments/src/Utilities.ts index 4661a875ed729..cce14ea6abb0f 100644 --- a/packages/experiments/src/Utilities.ts +++ b/packages/experiments/src/Utilities.ts @@ -1 +1 @@ -export * from 'office-ui-fabric-react/lib/Utilities'; +export * from 'office-ui-fabric-react/lib/Utilities'; \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/Keytip.styles.ts b/packages/experiments/src/components/Keytip/Keytip.styles.ts new file mode 100644 index 0000000000000..95630b3c7a7b5 --- /dev/null +++ b/packages/experiments/src/components/Keytip/Keytip.styles.ts @@ -0,0 +1,52 @@ +import { IKeytipStyleProps, IKeytipStyles } from './Keytip.types'; +import { ICalloutContentStyleProps, ICalloutContentStyles } from 'office-ui-fabric-react/lib/Callout'; + +export const getStyles = (props: IKeytipStyleProps): IKeytipStyles => { + const { theme, disabled, visible } = props; + return { + container: [ + { + backgroundColor: theme.palette.neutralDark + }, + disabled && { + opacity: 0.5, + }, + !visible && { + visibility: 'hidden' + } + ], + root: [{ + textAlign: 'center', + paddingLeft: 3, + paddingRight: 3, + backgroundColor: theme.palette.neutralDark, + color: theme.palette.neutralLight, + minWidth: 11, + lineHeight: 17, + height: 17, + display: 'inline-block' + }, + disabled && { + color: '#b1b1b1' + }] + }; +}; + +export const getCalloutStyles = (props: ICalloutContentStyleProps): ICalloutContentStyles => { + return { + container: [ + ], + root: [{ + border: 'none', + boxShadow: 'none' + }], + beak: [ + ], + beakCurtain: [ + ], + calloutMain: [{ + backgroundColor: 'transparent' + } + ] + }; +}; \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/Keytip.tsx b/packages/experiments/src/components/Keytip/Keytip.tsx new file mode 100644 index 0000000000000..4d99c15fefd76 --- /dev/null +++ b/packages/experiments/src/components/Keytip/Keytip.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { BaseComponent } from '../../Utilities'; +import { Callout } from 'office-ui-fabric-react/lib/Callout'; +import { DirectionalHint } from 'office-ui-fabric-react/lib/ContextualMenu'; +import { IKeytip, IKeytipProps } from './Keytip.types'; +import { KeytipContent } from './KeytipContent'; +import { getCalloutStyles } from './Keytip.styles'; +import { constructKeytipTargetFromSequences } from '../../utilities/keytip/KeytipUtils'; + +/** + * A callout corresponding to another Fabric component to describe a key sequence that will activate that component + * + * @export + * @class Keytip + * @extends {BaseComponent} + */ +export class Keytip extends BaseComponent implements IKeytip { + + // tslint:disable-next-line:no-any + constructor(props: IKeytipProps, context: any) { + super(props, context); + } + + public render(): JSX.Element { + const { + calloutProps, + keySequences, + offset = 0 // Default value for gap is 0 + } = this.props; + + return ( + + + + ); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/Keytip.types.ts b/packages/experiments/src/components/Keytip/Keytip.types.ts new file mode 100644 index 0000000000000..9f5c4094c8595 --- /dev/null +++ b/packages/experiments/src/components/Keytip/Keytip.types.ts @@ -0,0 +1,156 @@ +import { ICalloutProps } from 'office-ui-fabric-react/lib/Callout'; +import { IStyle, ITheme } from '../../Styling'; +import { IStyleFunction } from '../../Utilities'; +import { IKeySequence } from '../../utilities/keysequence/IKeySequence'; + +export interface IKeytip { +} + +export interface IKeytipProps { + /** + * Optional callback to access the Keytip component. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: (component: IKeytip) => void; + + /** + * Content to put inside the keytip + * + * @type {string} + */ + content: string; + + /** + * Theme for the component + * + * @type {ITheme} + */ + theme?: ITheme; + + /** + * T/F if the corresponding control is disabled + * + * @type {boolean} + */ + disabled?: boolean; + + /** + * T/F if the keytip is visible + * + * @type {boolean} + */ + visible?: boolean; + + /** + * Function to call when this keytip is activated + * 'el' is the DOM element that the keytip is attached to + * + * @type {(HTMLElement) => void} + */ + onExecute?: (el: HTMLElement) => void; + + /** + * Function to call when the keytip is returned to + * 'el' is the DOM element that the keytip is attached to + * + * @type {(HTMLElement) => void} + */ + onReturn?: (el: HTMLElement) => void; + + /** + * Array of KeySequences which is the full key sequence to trigger this keytip + * Should not include initial 'start' key sequence + * + * @type {IKeySequence[]} + */ + keySequences: IKeySequence[]; + + /** + * KeySequence of overflow set which will trigger the keytip. + * + * @type {IKeySequence} + */ + overflowSetSequence?: IKeySequence; + + /** + * ICalloutProps to pass to the callout element + * + * @type {string} + */ + calloutProps?: ICalloutProps; + + /** + * Optional styles for the component. + * + * @type {IStyleFunction} + */ + getStyles?: IStyleFunction; + + /** + * Offset distance in px between the target element and the positioning of the keytip.this keytip + * + * @type {number} + * @default 0 + */ + offset?: number; + + /** + * Whether or not this node has children nodes or not. Should be used for menus/overflow components, that have + * their children registered after the initial rendering of the DOM. + * + * @type {boolean} + */ + hasChildrenNodes?: boolean; +} + +/** + * Props to style Keytip component + */ +export interface IKeytipStyleProps { + + /** + * The theme for the keytip. + * + * @type {ITheme} + */ + theme: ITheme; + + /** + * Whether the keytip is disabled or not. + * + * @type {boolean} + */ + disabled?: boolean; + + /** + * T/F if the keytip is visible + * + * @type {boolean} + */ + visible?: boolean; +} + +export interface IKeytipStyles { + + /** + * Style for the div container surrounding the keytip content. + * + * @type {IStyle} + */ + container: IStyle; + + /** + * Style for the keytip content element. + * + * @type {IStyle} + */ + root: IStyle; +} + +export enum KeytipTransitionModifier { + shift = 16, + ctrl = 17, + alt = 18, + leftWindow = 91, + rightWindow = 92 +} \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/KeytipContent.base.tsx b/packages/experiments/src/components/Keytip/KeytipContent.base.tsx new file mode 100644 index 0000000000000..2068168345c58 --- /dev/null +++ b/packages/experiments/src/components/Keytip/KeytipContent.base.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { BaseComponent, classNamesFunction, customizable } from '../../Utilities'; +import { convertSequencesToKeytipID } from '../../utilities/keysequence/IKeySequence'; +import { IKeytipProps, IKeytipStyleProps, IKeytipStyles } from './Keytip.types'; + +/** + * A component corresponding the the content rendered inside the callout of the keytip component. + * + * @export + * @class KeytipContent + * @extends {BaseComponent} + */ +@customizable('KeytipContent', ['theme']) +export class KeytipContentBase extends BaseComponent { + + public render(): JSX.Element { + let { + content, + getStyles, + theme, + disabled, + keySequences, + visible + } = this.props; + + const getClassNames = classNamesFunction(); + let classNames = getClassNames( + getStyles!, + { + theme: theme!, + disabled, + visible + } + ); + + return ( +
+ { content } +
+ ); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/KeytipContent.test.tsx b/packages/experiments/src/components/Keytip/KeytipContent.test.tsx new file mode 100644 index 0000000000000..5edcb5e58c62c --- /dev/null +++ b/packages/experiments/src/components/Keytip/KeytipContent.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import { IKeySequence } from '../../utilities/keysequence/IKeySequence'; +import { KeytipContent } from './KeytipContent'; + +const sequence: IKeySequence[] = [{ keys: ['a'] }]; +const keyCont = 'A'; + +describe('Keytip', () => { + it('renders visible Keytip correctly', () => { + const componentContent = renderer.create(); + let tree = componentContent.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders visible disabled Keytip correctly', () => { + const componentContent = renderer.create( + ); + let tree = componentContent.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders invisible Keytip correctly', () => { + const componentContent = renderer.create(); + let tree = componentContent.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/KeytipContent.tsx b/packages/experiments/src/components/Keytip/KeytipContent.tsx new file mode 100644 index 0000000000000..9e83cf7ddd0e2 --- /dev/null +++ b/packages/experiments/src/components/Keytip/KeytipContent.tsx @@ -0,0 +1,9 @@ +import { styled } from '../../Utilities'; +import { IKeytipProps, IKeytipStyleProps, IKeytipStyles } from './Keytip.types'; +import { KeytipContentBase } from './KeytipContent.base'; +import { getStyles } from './Keytip.styles'; + +export const KeytipContent = styled( + KeytipContentBase, + getStyles +); \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/KeytipPage.tsx b/packages/experiments/src/components/Keytip/KeytipPage.tsx new file mode 100644 index 0000000000000..0e2c14c866702 --- /dev/null +++ b/packages/experiments/src/components/Keytip/KeytipPage.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { + ExampleCard, + IComponentDemoPageProps, + ComponentPage +} from '@uifabric/example-app-base'; +import { KeytipBasicExample } from './examples/Keytip.Basic.Example'; +import { KeytipDisabledExample } from './examples/Keytip.Disabled.Example'; +import { KeytipLanguageExample } from './examples/Keytip.Language.Example'; +import { KeytipLayer } from '../../KeytipLayer'; + +export class KeytipPage extends React.Component { + public render(): JSX.Element { + return ( +
+ + + + + + + + + + +
+ } + overview={ +
+

A Keytip is a small popup near a component that indicates a key sequence that will trigger that component. These are not + to be confused with keyboard shortcuts; they are instead key sequences to traverse through UI components. Technically, a + Keytip is a wrapper around a Callout where the target element is discovered through a 'data-ktp-id' attribute on that + element. +

+ +

The key sequence for a Keytip must be any combination of alphanumeric characters (A-Z, 0-9). No modifiers (Alt, Shift, + Ctrl) can be used for these sequences.

+
+ } + bestPractices={ +
+ } + dos={ +
+
    +
  • The content in the Keytip should only be the alphanumeric letters that will trigger this Keytip
  • +
+
+ } + donts={ +
+
    +
  • Don't add Keytips directly into your app. They should be added with the registerKeytip helper through another + component
  • +
+
+ } + isHeaderVisible={ this.props.isHeaderVisible } + /> + +
+ ); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/__snapshots__/KeytipContent.test.tsx.snap b/packages/experiments/src/components/Keytip/__snapshots__/KeytipContent.test.tsx.snap new file mode 100644 index 0000000000000..ba8fca629a859 --- /dev/null +++ b/packages/experiments/src/components/Keytip/__snapshots__/KeytipContent.test.tsx.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Keytip renders invisible Keytip correctly 1`] = ` +
+ + A + +
+`; + +exports[`Keytip renders visible Keytip correctly 1`] = ` +
+ + A + +
+`; + +exports[`Keytip renders visible disabled Keytip correctly 1`] = ` +
+ + A + +
+`; diff --git a/packages/experiments/src/components/Keytip/examples/Keytip.Basic.Example.tsx b/packages/experiments/src/components/Keytip/examples/Keytip.Basic.Example.tsx new file mode 100644 index 0000000000000..6695d48a76ba6 --- /dev/null +++ b/packages/experiments/src/components/Keytip/examples/Keytip.Basic.Example.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { IKeytipProps, Keytip } from '../../Keytip'; +import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { convertSequencesToKeytipID } from '../../../utilities/keysequence/IKeySequence'; + +export interface IKeytipExampleState { + keytipVisible: boolean; +} + +export interface IKeytipMap { + [componentKeytipId: string]: IKeytipProps; +} + +export function onKeytipButtonClick(): void { + this.setState((previousState: IKeytipExampleState) => { + let currentKeytipVisible = !previousState.keytipVisible; + return { keytipVisible: currentKeytipVisible }; + }); +} + +export class KeytipBasicExample extends React.Component<{}, IKeytipExampleState> { + private keytipMap: IKeytipMap = {}; + + constructor(props: {}) { + super(props); + + this.state = { + keytipVisible: false, + }; + + // Setup keytips + this.keytipMap.Keytip1 = { + content: 'A', + keySequences: [{ keys: ['a'] }], + } as IKeytipProps; + } + + /* tslint:disable:jsx-ban-props */ + public render(): JSX.Element { + let btnClick = onKeytipButtonClick.bind(this); + + return ( +
+ + +
+ ); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/examples/Keytip.Disabled.Example.tsx b/packages/experiments/src/components/Keytip/examples/Keytip.Disabled.Example.tsx new file mode 100644 index 0000000000000..71a3fd77aded8 --- /dev/null +++ b/packages/experiments/src/components/Keytip/examples/Keytip.Disabled.Example.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { IKeytipProps, Keytip } from '../../Keytip'; +import { convertSequencesToKeytipID } from '../../../utilities/keysequence/IKeySequence'; +import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { IKeytipExampleState, onKeytipButtonClick } from './Keytip.Basic.Example'; + +export interface IKeytipMap { + [componentKeytipId: string]: IKeytipProps; +} + +export class KeytipDisabledExample extends React.Component<{}, IKeytipExampleState> { + private keytipMap: IKeytipMap = {}; + + constructor(props: {}) { + super(props); + + this.state = { + keytipVisible: false + }; + + // Setup keytips + this.keytipMap.Keytip1 = { + content: 'B', + keySequences: [{ keys: ['b'] }], + disabled: true + } as IKeytipProps; + } + + /* tslint:disable:jsx-ban-props */ + public render(): JSX.Element { + let btnClick = onKeytipButtonClick.bind(this); + return ( +
+

A disabled keytip will be displayed when keytips are enabled, but the component will not + be activated when its keys are pressed

+ + +
+ ); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/examples/Keytip.Language.Example.tsx b/packages/experiments/src/components/Keytip/examples/Keytip.Language.Example.tsx new file mode 100644 index 0000000000000..e7baab3f1e9b9 --- /dev/null +++ b/packages/experiments/src/components/Keytip/examples/Keytip.Language.Example.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { IKeytipProps, Keytip } from '../../Keytip'; +import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { convertSequencesToKeytipID } from '../../../utilities/keysequence/IKeySequence'; +import { IKeytipExampleState, onKeytipButtonClick } from './Keytip.Basic.Example'; + +export interface IKeytipMap { + [componentKeytipId: string]: IKeytipProps; +} + +export class KeytipLanguageExample extends React.Component<{}, IKeytipExampleState> { + private keytipMap: IKeytipMap = {}; + + constructor(props: {}) { + super(props); + + this.state = { + keytipVisible: false, + }; + + // Setup keytips + this.keytipMap.Keytip1 = { + content: 'ы ñ خ', + keySequences: [{ keys: ['c'] }], + } as IKeytipProps; + } + + /* tslint:disable:jsx-ban-props */ + public render(): JSX.Element { + let btnClick = onKeytipButtonClick.bind(this); + return ( +
+

Keytips can support displaying and processing keys for any unicode language

+ + +
+ ); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/Keytip/index.ts b/packages/experiments/src/components/Keytip/index.ts new file mode 100644 index 0000000000000..e1a7019416b87 --- /dev/null +++ b/packages/experiments/src/components/Keytip/index.ts @@ -0,0 +1,2 @@ +export * from './Keytip'; +export * from './Keytip.types'; \ No newline at end of file diff --git a/packages/experiments/src/components/KeytipLayer/KeytipLayer.tsx b/packages/experiments/src/components/KeytipLayer/KeytipLayer.tsx new file mode 100644 index 0000000000000..806b112832552 --- /dev/null +++ b/packages/experiments/src/components/KeytipLayer/KeytipLayer.tsx @@ -0,0 +1,248 @@ +import * as React from 'react'; +import { IKeytipLayerProps } from './KeytipLayer.types'; +import { Keytip, IKeytipProps, KeytipTransitionModifier } from '../Keytip'; +import { + autobind, + BaseComponent +} from '../../Utilities'; +import { Layer } from 'office-ui-fabric-react/lib/Layer'; +import { KeyCodes, findIndex } from '../../Utilities'; +import { convertSequencesToKeytipID, fullKeySequencesAreEqual } from '../../utilities/keysequence/IKeySequence'; +import { IKeytipTransitionKey } from '../../utilities/keysequence/IKeytipTransitionKey'; +import { KeytipManager } from '../../utilities/keytip/KeytipManager'; +import { ktpFullPrefix, ktpSeparator } from '../../utilities/keytip/KeytipUtils'; + +export interface IKeytipLayerState { + inKeytipMode: boolean; + keytips: IKeytipProps[]; +} + +const defaultStartSequence: IKeytipTransitionKey = { + key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] +}; + +const defaultExitSequence: IKeytipTransitionKey = { + key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] +}; + +const defaultReturnSequence: IKeytipTransitionKey = { + key: 'Escape' +}; + +/** + * A layer that holds all keytip items + * + * @export + * @class KeytipLayer + * @extends {BaseComponent} + */ +export class KeytipLayer extends BaseComponent { + public static defaultProps: IKeytipLayerProps = { + keytipStartSequences: [defaultStartSequence], + keytipExitSequences: [defaultExitSequence], + keytipReturnSequences: [defaultReturnSequence], + id: ktpFullPrefix + 'Alt' + ktpSeparator + 'Meta' + }; + + private _keytipManager: KeytipManager = KeytipManager.getInstance(); + + // tslint:disable-next-line:no-any + constructor(props: IKeytipLayerProps, context: any) { + super(props, context); + + this.state = { + inKeytipMode: false, + keytips: [] + }; + + this._keytipManager.init(this); + } + + /** + * Register a keytip in this layer + * + * @param keytipProps - IKeytipProps to add to this layer + */ + public registerKeytip(keytipProps: IKeytipProps): void { + this.setState(this.addKeytip(keytipProps)); + } + + /** + * Unregister a keytip in this layer + * + * @param keytipProps - IKeytipProps to remove from this layer + */ + public unregisterKeytip(keytipProps: IKeytipProps): void { + this.setState(this.removeKeytip(keytipProps)); + } + + /** + * Add or update a keytip to this layer by modifying this layer's state + * + * @param keytipProps - Keytip to add or update in the layer + * @returns Function to call with setState + */ + public addKeytip(keytipProps: IKeytipProps): {} { + return (previousState: IKeytipLayerState) => { + let previousKeytips: IKeytipProps[] = previousState.keytips; + + // Try to find keytipProps in previousKeytips to update + let keytipToUpdateIndex = findIndex(previousKeytips, (previousKeytip: IKeytipProps) => { + return fullKeySequencesAreEqual(keytipProps.keySequences, previousKeytip.keySequences); + }); + + let currentKeytips = [...previousState.keytips]; + if (keytipToUpdateIndex >= 0) { + // Replace the keytip props + currentKeytips.splice(keytipToUpdateIndex, 1, keytipProps); + } else { + // Add the new keytip props + currentKeytips.push(keytipProps); + } + return { ...previousState, keytips: currentKeytips }; + }; + } + + /** + * Removes a keytip from this layer's state + * + * @param keytipToRemove - IKeytipProps of the keytip to remove + * @returns Function to call with setState + */ + public removeKeytip(keytipToRemove: IKeytipProps): {} { + return (previousState: IKeytipLayerState) => { + let currentKeytips = previousState.keytips; + // Filter out keytips that don't equal the one to remove + let filteredKeytips: IKeytipProps[] = currentKeytips.filter((currentKeytip: IKeytipProps) => { + return !fullKeySequencesAreEqual(currentKeytip.keySequences, keytipToRemove.keySequences); + }); + return { ...previousState, keytips: filteredKeytips }; + }; + } + + /** + * Sets the visibility of the keytips in this layer + * + * @param ids - Keytip IDs that should have their visibility updated + * @param visible - T/F if the specified Keytips will be visible or not + */ + public setKeytipVisibility(ids: string[], visible: boolean): void { + this.setState((previousState: IKeytipLayerState, currentProps: IKeytipLayerState) => { + let currentKeytips: IKeytipProps[] = [...previousState.keytips]; + for (let keytip of currentKeytips) { + let keytipId = convertSequencesToKeytipID(keytip.keySequences); + if (ids.indexOf(keytipId) >= 0) { + keytip.visible = visible; + } + } + return { ...previousState, keytips: currentKeytips }; + }); + } + + public render(): JSX.Element { + const { + id + } = this.props; + + const { + keytips + } = this.state; + + return ( + + { keytips && keytips.map((keytipProps: IKeytipProps, index: number) => { + return ; + }) } + + ); + } + + public componentDidMount(): void { + this._events.on(window, 'mousedown', this._onDismiss); + this._events.on(window, 'resize', this._onDismiss); + this._events.on(window, 'keydown', this._onKeyDown, true /* useCapture */); + this._events.on(window, 'keypress', this._onKeyPress, true /* useCapture */); + + // Add handler to remove Keytips when we scroll the page + window.addEventListener('scroll', (): void => { + if (this.state.inKeytipMode) { + this._keytipManager.exitKeytipMode(); + } + }, { capture: true }); + } + + /** + * Exits keytip mode for this layer + */ + public exitKeytipMode(): void { + if (this.props.onExitKeytipMode) { + this.props.onExitKeytipMode(); + } + this.setState({ inKeytipMode: false }); + } + + /** + * Enters keytip mode for this layer + */ + public enterKeytipMode(): void { + if (this.props.onEnterKeytipMode) { + this.props.onEnterKeytipMode(); + } + this.setState({ inKeytipMode: true }); + } + + @autobind + private _onDismiss(ev?: React.MouseEvent): void { + // if we are in keytip mode, then exit keytip mode + if (this.state.inKeytipMode) { + this._keytipManager.exitKeytipMode(); + } + } + + @autobind + private _onKeyDown(ev: React.KeyboardEvent): void { + switch (ev.which) { + case KeyCodes.alt: + // ALT puts focus in the browser bar, so it should not be used as a key for keytips. + // It can be used as a modifier + break; + case KeyCodes.tab: + case KeyCodes.enter: + case KeyCodes.space: + this._keytipManager.exitKeytipMode(); + break; + default: + let transitionKey: IKeytipTransitionKey = { key: ev.key }; + transitionKey.modifierKeys = this._getModifierKey(ev); + this._keytipManager.processTransitionInput(transitionKey); + break; + } + } + + /** + * Gets the ModifierKeyCodes based on the keyboard event + * + * @param ev - React.KeyboardEvent + * @returns List of ModifierKeyCodes that were pressed + */ + private _getModifierKey(ev: React.KeyboardEvent): KeytipTransitionModifier[] | undefined { + let modifierKeys = []; + if (ev.altKey) { + modifierKeys.push(KeytipTransitionModifier.alt); + } + if (ev.ctrlKey) { + modifierKeys.push(KeytipTransitionModifier.ctrl); + } + if (ev.shiftKey) { + modifierKeys.push(KeytipTransitionModifier.shift); + } + // TODO: include windows key or option for MAC + return modifierKeys.length ? modifierKeys : undefined; + } + + @autobind + private _onKeyPress(ev: React.KeyboardEvent): void { + // Call processInput + this._keytipManager.processInput(ev.key); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/KeytipLayer/KeytipLayer.types.ts b/packages/experiments/src/components/KeytipLayer/KeytipLayer.types.ts new file mode 100644 index 0000000000000..c060857d2b8a0 --- /dev/null +++ b/packages/experiments/src/components/KeytipLayer/KeytipLayer.types.ts @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { IKeytipTransitionKey } from '../../utilities/keysequence/IKeytipTransitionKey'; +import { KeytipLayer } from './KeytipLayer'; + +export interface IKeytipLayerProps extends React.Props { + /** + * Optional callback to access the KeytipLayer component. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: (component: KeytipLayer) => void; + + /** + * The DOM ID to use as the hostId for the child keytips + * + * @type {string} + */ + id: string; + + /** + * List of key sequences that will start keytips mode + * + * @type {KeySequence} + */ + keytipStartSequences?: IKeytipTransitionKey[]; + + /** + * List of key sequences that execute the return functionality in keytips (going back to the previous level of keytips) + * + * @type {KeySequence} + */ + keytipReturnSequences?: IKeytipTransitionKey[]; + + /** + * List of key sequences that will exit keytips mode + * + * @type {KeySequence} + */ + keytipExitSequences?: IKeytipTransitionKey[]; + + /** + * Callback function triggered when keytip mode is exited + * + * @type {() => void} + */ + onExitKeytipMode?: () => void; + + /** + * Callback function triggered when keytip mode is entered + * + * @type {() => void)} + */ + onEnterKeytipMode?: () => void; +} \ No newline at end of file diff --git a/packages/experiments/src/components/KeytipLayer/KeytipLayerPage.tsx b/packages/experiments/src/components/KeytipLayer/KeytipLayerPage.tsx new file mode 100644 index 0000000000000..e053c602bdd29 --- /dev/null +++ b/packages/experiments/src/components/KeytipLayer/KeytipLayerPage.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { + ExampleCard, + IComponentDemoPageProps, + ComponentPage +} from '@uifabric/example-app-base'; +import { KeytipLayerBasicExample } from './examples/KeytipLayer.Basic.Example'; + +export class KeytipLayerPage extends React.Component { + public render(): JSX.Element { + return ( + + + + + + } + overview={ +
+

A KeytipLayer is the component to add to your app to enable Keytips. It can be added anywhere in your document, but must + only be added once. Use the registerKeytip utility helper to add a Keytip to your app through the component render() function + it belongs to.

+

Key sequences can be defined to enter and exit Keytip mode. These sequences can be any keys along with modifiers (Alt, Shift + Ctrl, etc). The enter and exit sequences default to Alt-Win. There is also a sequence to 'return' up a level of keytips, this + defaults to 'Esc'.

+
+ } + bestPractices={ +
+ } + dos={ +
+
    +
  • Keytip sequences can be duplicated as long as none of their siblings have the same sequence
  • +
+
+ } + donts={ +
+
    +
  • Don't create more than 1 KeytipLayer per app. This will cause issues with the key listeners
  • +
+
+ } + isHeaderVisible={ this.props.isHeaderVisible } + /> + ); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/KeytipLayer/examples/KeytipLayer.Basic.Example.tsx b/packages/experiments/src/components/KeytipLayer/examples/KeytipLayer.Basic.Example.tsx new file mode 100644 index 0000000000000..ad1ba8cb4b812 --- /dev/null +++ b/packages/experiments/src/components/KeytipLayer/examples/KeytipLayer.Basic.Example.tsx @@ -0,0 +1,237 @@ +import * as React from 'react'; +import { convertSequencesToKeytipID } from '../../../utilities/keysequence/IKeySequence'; +import { IKeytipTransitionKey } from '../../../utilities/keysequence/IKeytipTransitionKey'; +import { registerKeytip, addKeytipSequence } from '../../../utilities/keytip/KeytipUtils'; +import { KeytipLayer } from '../KeytipLayer'; +import { IKeytipProps, KeytipTransitionModifier } from '../../Keytip'; +import { DefaultButton, ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { CommandBar } from 'office-ui-fabric-react/lib/CommandBar'; +import { Modal } from 'office-ui-fabric-react/lib/Modal'; +import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar'; +import { autobind } from '../../../Utilities'; + +export interface IKeytipLayerBasicExampleState { + showModal: boolean; + showMessageBar: boolean; +} + +export interface IKeytipMap { + [componentKeytipId: string]: IKeytipProps; +} + +export class KeytipLayerBasicExample extends React.Component<{}, IKeytipLayerBasicExampleState> { + + private startingKeySequence: IKeytipTransitionKey = { key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] }; + private keytipMap: IKeytipMap = {}; + + constructor(props: {}) { + super(props); + + // Setup state + this.state = { + showModal: false, + showMessageBar: false + }; + + // Setup keytips + this.keytipMap.Pivot1Keytip = { + content: 'A', + keySequences: [{ keys: ['a'] }] + } as IKeytipProps; + + this.keytipMap.Pivot2Keytip = { + content: 'B', + keySequences: [{ keys: ['b'] }] + } as IKeytipProps; + + this.keytipMap.Button1Pivot1Keytip = { + content: '1B', + keySequences: addKeytipSequence(this.keytipMap.Pivot1Keytip.keySequences, { keys: ['1', 'b'] }), + onExecute: (el: HTMLElement) => { + el.click(); + } + } as IKeytipProps; + + this.keytipMap.Button2Pivot1Keytip = { + content: '1A', + keySequences: addKeytipSequence(this.keytipMap.Pivot1Keytip.keySequences, { keys: ['1', 'a'] }), + onExecute: (el: HTMLElement) => { + el.click(); + } + } as IKeytipProps; + + this.keytipMap.Button3Pivot1Keytip = { + content: 'M', + keySequences: addKeytipSequence(this.keytipMap.Pivot1Keytip.keySequences, { keys: ['m'] }), + onExecute: (el: HTMLElement) => { + el.click(); + } + } as IKeytipProps; + + this.keytipMap.CommandButton1Pivot2Keytip = { + content: '2', + keySequences: addKeytipSequence(this.keytipMap.Pivot2Keytip.keySequences, { keys: ['2'] }), + onExecute: (el: HTMLElement) => { + el.click(); + } + } as IKeytipProps; + + this.keytipMap.CommandButton2Pivot2Keytip = { + content: 'Y', + keySequences: addKeytipSequence(this.keytipMap.Pivot2Keytip.keySequences, { keys: ['y'] }), + onExecute: (el: HTMLElement) => { + el.click(); + } + } as IKeytipProps; + + this.keytipMap.CommandButton3Pivot2Keytip = { + content: 'LK', + keySequences: addKeytipSequence(this.keytipMap.Pivot2Keytip.keySequences, { keys: ['l', 'k'] }), + onExecute: (el: HTMLElement) => { + el.click(); + } + } as IKeytipProps; + } + + /* tslint:disable:jsx-ban-props jsx-no-lambda */ + public render(): JSX.Element { + let divStyle = { + width: '50%', + display: 'inline-block', + verticalAlign: 'top' + }; + + return ( +
+

Press Alt-Win to enable keytips, Esc to return up a level, and Alt-Win to exit keytip mode

+
+
+
+ +
+
+ { alert('Button 1'); } } + /> + { alert('Button 2'); } } + /> + { alert('Button 3'); } } + /> +
+
+
+
+ +
+ +
+
+ { this.state.showMessageBar && + + Success Uploading + + } + +

Hello this is a Modal

+
+ +
+ ); + } + + public componentDidMount(): void { + // Manually add keytips to the KeytipManager for now + // This should really be done in each component + for (let component of Object.keys(this.keytipMap)) { + registerKeytip(this.keytipMap[component]); + } + } + + @autobind + private _showModal(): void { + this.setState({ showModal: true }); + } + + @autobind + private _hideModal(): void { + this.setState({ showModal: false }); + } + + @autobind + private _showMessageBar(): void { + this.setState({ showMessageBar: true }); + + // Hide the MessageBar after 2 seconds + setTimeout(this._hideMessageBar, 2000); + } + + @autobind + private _hideMessageBar(): void { + this.setState({ showMessageBar: false }); + } +} \ No newline at end of file diff --git a/packages/experiments/src/components/KeytipLayer/index.ts b/packages/experiments/src/components/KeytipLayer/index.ts new file mode 100644 index 0000000000000..d05637efc9388 --- /dev/null +++ b/packages/experiments/src/components/KeytipLayer/index.ts @@ -0,0 +1,2 @@ +export * from './KeytipLayer'; +export * from './KeytipLayer.types'; \ No newline at end of file diff --git a/packages/experiments/src/demo/AppDefinition.tsx b/packages/experiments/src/demo/AppDefinition.tsx index b009e29266e79..dbac4ab092ff0 100644 --- a/packages/experiments/src/demo/AppDefinition.tsx +++ b/packages/experiments/src/demo/AppDefinition.tsx @@ -34,6 +34,18 @@ export const AppDefinition: IAppDefinition = { name: 'FileTypeIcon', url: '#/examples/filetypeicon' }, + { + component: require('../components/Keytip/KeytipPage').KeytipPage, + key: 'Keytip', + name: 'Keytip', + url: '#/examples/keytip' + }, + { + component: require('../components/KeytipLayer/KeytipLayerPage').KeytipLayerPage, + key: 'KeytipLayer', + name: 'KeytipLayer', + url: '#/examples/keytipLayer' + }, { component: require('../components/LayoutGroup/LayoutGroupPage').LayoutGroupPage, key: 'LayoutGroup', diff --git a/packages/experiments/src/utilities/keysequence/IKeySequence.test.ts b/packages/experiments/src/utilities/keysequence/IKeySequence.test.ts new file mode 100644 index 0000000000000..0e1c057e9d82a --- /dev/null +++ b/packages/experiments/src/utilities/keysequence/IKeySequence.test.ts @@ -0,0 +1,157 @@ +import { + IKeySequence, + keySequencesAreEqual, + keySequenceStartsWith, + keySequencesContain, + convertSequencesToKeytipID, + fullKeySequencesAreEqual +} from './IKeySequence'; +import { ktpFullPrefix, ktpSeparator } from '../keytip/KeytipUtils'; + +describe('IKeySequence', () => { + + describe('keySequencesAreEqual', () => { + it('empty key', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let seq2: IKeySequence = { keys: [] }; + expect(keySequencesAreEqual(seq1, seq2)).toEqual(false); + }); + + it('single key', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let seq2: IKeySequence = { keys: ['a'] }; + let seq3: IKeySequence = { keys: ['b'] }; + expect(keySequencesAreEqual(seq1, seq2)).toEqual(true); + expect(keySequencesAreEqual(seq1, seq3)).toEqual(false); + }); + + it('multiple keys', () => { + let seq1: IKeySequence = { keys: ['a', 'b'] }; + let seq2: IKeySequence = { keys: ['a', 'b'] }; + let seq3: IKeySequence = { keys: ['b', 'a'] }; + expect(keySequencesAreEqual(seq1, seq2)).toEqual(true); + expect(keySequencesAreEqual(seq1, seq3)).toEqual(false); + }); + + it('should be false when sequences are different length', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let seq2: IKeySequence = { keys: ['a', 'b'] }; + expect(keySequencesAreEqual(seq1, seq2)).toEqual(false); + }); + }); + + describe('keySequencesContain', () => { + it('empty sequences', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let sequences: IKeySequence[] = [{ keys: [] }]; + expect(keySequencesContain(sequences, seq1)).toEqual(false); + }); + + it('empty key sequence', () => { + let seq1: IKeySequence = { keys: [] }; + let sequences: IKeySequence[] = [{ keys: ['a'] }, { keys: ['b'] }]; + expect(keySequencesContain(sequences, seq1)).toEqual(false); + }); + + it('single key', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let sequences: IKeySequence[] = [{ keys: ['a'] }, { keys: ['b'] }]; + let sequences2: IKeySequence[] = [{ keys: ['a', 'b'] }]; + expect(keySequencesContain(sequences, seq1)).toEqual(true); + expect(keySequencesContain(sequences2, seq1)).toEqual(false); + }); + + it('multiple keys', () => { + let seq1: IKeySequence = { keys: ['a', 'b'] }; + let sequences: IKeySequence[] = [{ keys: ['a'] }, { keys: ['b'] }]; + let sequences2: IKeySequence[] = [{ keys: ['a', 'b'] }, { keys: ['c', 'd'] }]; + expect(keySequencesContain(sequences, seq1)).toEqual(false); + expect(keySequencesContain(sequences2, seq1)).toEqual(true); + }); + }); + + describe('keySequencesStartsWith', () => { + it('false when the key sequence for seq1 is zero ', () => { + let seq1: IKeySequence = { keys: [] }; + let seq2: IKeySequence = { keys: ['b'] }; + expect(keySequenceStartsWith(seq1, seq2)).toEqual(false); + }); + + it('false when the key sequence for seq2 is zero ', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let seq2: IKeySequence = { keys: [] }; + expect(keySequenceStartsWith(seq1, seq2)).toEqual(false); + }); + + it('false when sequence start is different', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let seq2: IKeySequence = { keys: ['b'] }; + expect(keySequenceStartsWith(seq1, seq2)).toEqual(false); + }); + + it('true when sequences are equal', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let seq2: IKeySequence = { keys: ['a'] }; + expect(keySequenceStartsWith(seq1, seq2)).toEqual(true); + }); + + it('true when sequence1 is a subset of sequence 2', () => { + let seq1: IKeySequence = { keys: ['a'] }; + let seq2: IKeySequence = { keys: ['a', 'b'] }; + expect(keySequenceStartsWith(seq1, seq2)).toEqual(true); + }); + + it('true when sequence2 is a subset of sequence 1', () => { + let seq1: IKeySequence = { keys: ['a', 'b'] }; + let seq2: IKeySequence = { keys: ['a'] }; + expect(keySequenceStartsWith(seq1, seq2)).toEqual(true); + }); + }); + + describe('convertSequencesToKeytipID', () => { + it('for one singular key sequence', () => { + let keySequence: IKeySequence[] = [{ keys: ['a'] }]; + let keytipID = convertSequencesToKeytipID(keySequence); + expect(keytipID).toEqual(ktpFullPrefix + 'a'); + }); + + it('for one complex key sequence', () => { + let complexKeySequence: IKeySequence[] = [{ keys: ['a', 'd'] }]; + let keytipID = convertSequencesToKeytipID(complexKeySequence); + expect(keytipID).toEqual(ktpFullPrefix + 'a' + ktpSeparator + 'd'); + }); + + it('for multiple singular key sequences', () => { + let keySequences: IKeySequence[] = [{ keys: ['a'] }, { keys: ['c'] }]; + let keytipID = convertSequencesToKeytipID(keySequences); + expect(keytipID).toEqual(ktpFullPrefix + 'a' + ktpSeparator + 'c'); + }); + + it('for multiple complex key sequences', () => { + let complexKeySequences: IKeySequence[] = [{ keys: ['a', 'n'] }, { keys: ['c', 'b'] }]; + let keytipID = convertSequencesToKeytipID(complexKeySequences); + expect(keytipID).toEqual(ktpFullPrefix + 'a' + + ktpSeparator + 'n' + ktpSeparator + + 'c' + ktpSeparator + 'b'); + }); + }); + + describe('fullKeySequencesAreEqual', () => { + it('different lengths should not be equal', () => { + let keySequences1: IKeySequence[] = [{ keys: ['a', 'n'] }, { keys: ['c', 'b'] }]; + let keySequences2: IKeySequence[] = [{ keys: ['a', 'n'] }]; + expect(fullKeySequencesAreEqual(keySequences1, keySequences2)).toEqual(false); + }); + + it('should correctly be equal', () => { + let keySequences1: IKeySequence[] = [{ keys: ['a', 'n'] }, { keys: ['c', 'b'] }]; + let keySequences2: IKeySequence[] = [{ keys: ['a', 'n'] }, { keys: ['c', 'b'] }]; + let keySequences3: IKeySequence[] = [{ keys: ['n', 'a'] }, { keys: ['c', 'b'] }]; + let keySequences4: IKeySequence[] = [{ keys: ['a'] }, { keys: ['c', 'b'] }]; + + expect(fullKeySequencesAreEqual(keySequences1, keySequences2)).toEqual(true); + expect(fullKeySequencesAreEqual(keySequences1, keySequences3)).toEqual(false); + expect(fullKeySequencesAreEqual(keySequences1, keySequences4)).toEqual(false); + }); + }); +}); \ No newline at end of file diff --git a/packages/experiments/src/utilities/keysequence/IKeySequence.ts b/packages/experiments/src/utilities/keysequence/IKeySequence.ts new file mode 100644 index 0000000000000..f7f6e7a57731e --- /dev/null +++ b/packages/experiments/src/utilities/keysequence/IKeySequence.ts @@ -0,0 +1,83 @@ +import { ktpPrefix, ktpSeparator } from '../keytip/KeytipUtils'; +import { find } from '../../Utilities'; + +export interface IKeySequence { + keys: string[]; +} + +/** + * Tests for equality between two IKeySequences + * + * @param seq1 - First IKeySequence + * @param seq2 - Second IKeySequence + * @returns {boolean} T/F if the two sequences are equal + */ +export function keySequencesAreEqual(seq1: IKeySequence, seq2: IKeySequence): boolean { + let keyCodes1 = seq1.keys.join(); + let keyCodes2 = seq2.keys.join(); + return keyCodes1 === keyCodes2; +} + +/** + * Tests for equality between two arrays of IKeySequence + * + * @param seq1 - First IKeySequence[] + * @param seq2 - Second IKeySequence[] + * @returns {boolean} T/F if the sequences are equal + */ +export function fullKeySequencesAreEqual(seq1: IKeySequence[], seq2: IKeySequence[]): boolean { + if (seq1.length !== seq2.length) { + return false; + } + + for (let i = 0; i < seq1.length; i++) { + if (!keySequencesAreEqual(seq1[i], seq2[i])) { + return false; + } + } + + return true; +} + +/** + * Tests if 'seq' is present in 'sequences' + * + * @param sequences - Array of IKeySequence + * @param seq - IKeySequence to check for in 'sequences' + * @returns {boolean} T/F if 'sequences' contains 'seq' + */ +export function keySequencesContain(sequences: IKeySequence[], seq: IKeySequence): boolean { + return !!find(sequences, (sequence: IKeySequence) => { + return keySequencesAreEqual(sequence, seq); + }); +} + +/** + * Method returns true if the key squence of the object with minimum length is in the other key sequence. + * If the minium length is zero, then it will default to false. + * + * @param seq1 - First IKeySequence + * @param seq2 - Second IKeySequence + * @returns {boolean} T/F if seq1 starts with seq2, or vice versa + */ +export function keySequenceStartsWith(seq1: IKeySequence, seq2: IKeySequence): boolean { + let keyCodes1 = seq1.keys.join(); + let keyCodes2 = seq2.keys.join(); + if (keyCodes1.length === 0 || keyCodes2.length === 0) { + return false; + } + return keyCodes1.indexOf(keyCodes2) === 0 || keyCodes2.indexOf(keyCodes1) === 0; +} + +/** + * Converts a whole set of KeySequences into one keytip ID, which will be the ID for the last keytip sequence specified + * keySequences should not include the initial keytip 'start' sequence + * + * @param keySequences - Full path of IKeySequences for one keytip + * @returns {string} String to use for the keytip ID + */ +export function convertSequencesToKeytipID(keySequences: IKeySequence[]): string { + return keySequences.reduce((prevValue: string, keySequence: IKeySequence): string => { + return prevValue + ktpSeparator + keySequence.keys.join(ktpSeparator); + }, ktpPrefix); +} \ No newline at end of file diff --git a/packages/experiments/src/utilities/keysequence/IKeytipTransitionKey.test.ts b/packages/experiments/src/utilities/keysequence/IKeytipTransitionKey.test.ts new file mode 100644 index 0000000000000..0024e7ae23188 --- /dev/null +++ b/packages/experiments/src/utilities/keysequence/IKeytipTransitionKey.test.ts @@ -0,0 +1,59 @@ +import { IKeytipTransitionKey, transitionKeysAreEqual, transitionKeysContain } from './IKeytipTransitionKey'; +import { KeytipTransitionModifier } from '../../Keytip'; + +describe('IKeytipTransitionKey', () => { + + describe('transitionKeysAreEqual', () => { + it('key only equality', () => { + let key1: IKeytipTransitionKey = { key: 'a' }; + let key2: IKeytipTransitionKey = { key: 'b' }; + let key3: IKeytipTransitionKey = { key: 'a' }; + + expect(transitionKeysAreEqual(key1, key2)).toEqual(false); + expect(transitionKeysAreEqual(key1, key3)).toEqual(true); + }); + + it('key and modifier equality', () => { + let key1: IKeytipTransitionKey = { key: 'a', modifierKeys: [KeytipTransitionModifier.alt] }; + let key2: IKeytipTransitionKey = { key: 'a' }; + let key3: IKeytipTransitionKey = { key: 'a', modifierKeys: [KeytipTransitionModifier.ctrl] }; + let key4: IKeytipTransitionKey = { key: 'a', modifierKeys: [KeytipTransitionModifier.alt, KeytipTransitionModifier.shift] }; + let key5: IKeytipTransitionKey = { key: 'a', modifierKeys: [KeytipTransitionModifier.shift, KeytipTransitionModifier.alt] }; + + expect(transitionKeysAreEqual(key1, key2)).toEqual(false); + expect(transitionKeysAreEqual(key1, key3)).toEqual(false); + expect(transitionKeysAreEqual(key1, key4)).toEqual(false); + // Order doesn't matter + expect(transitionKeysAreEqual(key4, key5)).toEqual(true); + }); + }); + + describe('transitionKeySequencesContain', () => { + it('key only', () => { + let key1: IKeytipTransitionKey = { key: 'a' }; + + let keys1: IKeytipTransitionKey[] = [{ key: 'a' }]; + let keys2: IKeytipTransitionKey[] = [{ key: 'a' }, { key: 'b' }]; + let keys3: IKeytipTransitionKey[] = [{ key: 'c' }, { key: 'b' }]; + + expect(transitionKeysContain(keys1, key1)).toEqual(true); + expect(transitionKeysContain(keys2, key1)).toEqual(true); + expect(transitionKeysContain(keys3, key1)).toEqual(false); + }); + + it('key and modifier', () => { + let key1: IKeytipTransitionKey = { key: 'a', modifierKeys: [KeytipTransitionModifier.alt] }; + + let keys1: IKeytipTransitionKey[] = [{ key: 'a', modifierKeys: [KeytipTransitionModifier.alt] }]; + let keys2: IKeytipTransitionKey[] = [{ key: 'b', modifierKeys: [KeytipTransitionModifier.alt] }]; + let keys3: IKeytipTransitionKey[] = [ + { key: 'a', modifierKeys: [KeytipTransitionModifier.alt, KeytipTransitionModifier.ctrl] }, + { key: 'a', modifierKeys: [KeytipTransitionModifier.alt] } + ]; + + expect(transitionKeysContain(keys1, key1)).toEqual(true); + expect(transitionKeysContain(keys2, key1)).toEqual(false); + expect(transitionKeysContain(keys3, key1)).toEqual(true); + }); + }); +}); \ No newline at end of file diff --git a/packages/experiments/src/utilities/keysequence/IKeytipTransitionKey.ts b/packages/experiments/src/utilities/keysequence/IKeytipTransitionKey.ts new file mode 100644 index 0000000000000..9c02307f9a6e7 --- /dev/null +++ b/packages/experiments/src/utilities/keysequence/IKeytipTransitionKey.ts @@ -0,0 +1,58 @@ +import { KeytipTransitionModifier } from '../../Keytip'; +import { find } from '../../Utilities'; + +export interface IKeytipTransitionKey { + key: string; + modifierKeys?: KeytipTransitionModifier[]; +} + +/** + * Tests for equality between two IKeytipTransitionKeys + * + * @param key1 - First IKeytipTransitionKey + * @param key2 - Second IKeytipTransitionKey + * @returns {boolean} T/F if the transition keys are equal + */ +export function transitionKeysAreEqual(key1: IKeytipTransitionKey, key2: IKeytipTransitionKey): boolean { + if (key1.key !== key2.key) { + return false; + } + + let mod1 = key1.modifierKeys; + let mod2 = key2.modifierKeys; + + if ((!mod1 && mod2) || (mod1 && !mod2)) { + // Not equal if one modifier is defined and the other isn't + return false; + } + + if (mod1 && mod2) { + if (mod1.length !== mod2.length) { + return false; + } + + // Sort both arrays + mod1 = mod1.sort(); + mod2 = mod2.sort(); + for (let i = 0; i < mod1.length; i++) { + if (mod1[i] !== mod2[i]) { + return false; + } + } + } + + return true; +} + +/** + * Tests if 'key' is present in 'keys' + * + * @param keys - Array of IKeytipTransitionKey + * @param key - IKeytipTransitionKey to find in 'keys' + * @returns {boolean} T/F if 'keys' contains 'key' + */ +export function transitionKeysContain(keys: IKeytipTransitionKey[], key: IKeytipTransitionKey): boolean { + return !!find(keys, (transitionKey: IKeytipTransitionKey) => { + return transitionKeysAreEqual(transitionKey, key); + }); +} \ No newline at end of file diff --git a/packages/experiments/src/utilities/keytip/KeytipManager.test.tsx b/packages/experiments/src/utilities/keytip/KeytipManager.test.tsx new file mode 100644 index 0000000000000..210b8921341a7 --- /dev/null +++ b/packages/experiments/src/utilities/keytip/KeytipManager.test.tsx @@ -0,0 +1,326 @@ +import * as React from 'react'; + +import { KeytipManager } from './KeytipManager'; +import { IKeySequence, convertSequencesToKeytipID } from '../../utilities/keysequence/IKeySequence'; +import { IKeytipTransitionKey } from '../../utilities/keysequence/IKeytipTransitionKey'; +import { KeytipTree, IKeytipTreeNode } from './KeytipTree'; +import { KeytipLayer } from '../../KeytipLayer'; +import { IKeytipProps, KeytipTransitionModifier } from '../../Keytip'; +import { ktpSeparator, ktpFullPrefix } from '../../utilities/keytip/KeytipUtils'; +import { mount, ReactWrapper } from 'enzyme'; + +const keytipStartSequences: IKeytipTransitionKey[] = [{ key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] }]; +const keytipExitSequences: IKeytipTransitionKey[] = [{ key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] }]; +const keytipReturnSequences: IKeytipTransitionKey[] = [{ key: 'Escape' }]; +const layerID = 'my-layer-id'; +const keytipIdB = ktpFullPrefix + 'b'; +const keytipIdC = ktpFullPrefix + 'c'; +const keytipIdE1 = ktpFullPrefix + 'e' + ktpSeparator + '1'; +const keytipIdE2 = ktpFullPrefix + 'e' + ktpSeparator + '2'; +const keytipOverflowIdM = ktpFullPrefix + 'o' + ktpSeparator + 'm'; + +describe('KeytipManager', () => { + const keytipManager = KeytipManager.getInstance(); + const onEnterKeytipMode: jest.Mock = jest.fn(); + const onExitKeytipMode: jest.Mock = jest.fn(); + + let defaultKeytipLayer: ReactWrapper; + + beforeEach(() => { + // Create layer + defaultKeytipLayer = mount( + + ); + }); + + describe('getAriaDescribedBy', () => { + + it('returns just the layer ID when an empty sequence is passed in', () => { + let keySequence: IKeySequence[] = []; + let ariaDescribedBy = keytipManager.getAriaDescribedBy(keySequence); + expect(ariaDescribedBy).toEqual(layerID); + }); + + it('for one singular key sequence', () => { + let keySequence: IKeySequence[] = [{ keys: ['b'] }]; + let ariaDescribedBy = keytipManager.getAriaDescribedBy(keySequence); + expect(ariaDescribedBy).toEqual(layerID + ' ' + convertSequencesToKeytipID(keySequence)); + }); + + it('for one complex key sequence', () => { + let keySequence: IKeySequence[] = [{ keys: ['b', 'c'] }]; + let ariaDescribedBy = keytipManager.getAriaDescribedBy(keySequence); + expect(ariaDescribedBy).toEqual(layerID + ' ' + convertSequencesToKeytipID(keySequence)); + }); + + it('for multiple singular key sequences', () => { + let keySequences: IKeySequence[] = [{ keys: ['b'] }, { keys: ['c'] }]; + let ariaDescribedBy = keytipManager.getAriaDescribedBy(keySequences); + expect(ariaDescribedBy).toEqual(layerID + + ' ' + convertSequencesToKeytipID([keySequences[0]]) + + ' ' + convertSequencesToKeytipID(keySequences)); + }); + + it('for multiple complex key sequences', () => { + let keySequences: IKeySequence[] = [{ keys: ['a', 'n'] }, { keys: ['c', 'b'] }]; + let ariaDescribedBy = keytipManager.getAriaDescribedBy(keySequences); + expect(ariaDescribedBy).toEqual(layerID + + ' ' + convertSequencesToKeytipID([keySequences[0]]) + + ' ' + convertSequencesToKeytipID(keySequences)); + }); + }); + + describe('processInput tests', () => { + + beforeEach(() => { + keytipManager.keytipTree = populateTreeMap(keytipManager.keytipTree, layerID); + }); + + // On Exit keytip mode + it('Call on exit keytip mode when we process alt + left win ', () => { + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.root; + keytipManager.processTransitionInput({ key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] }); + expect(onExitKeytipMode).toBeCalled(); + }); + + // On Enter keytip mode + it('Call on enter keytip mode when we process alt + left win', () => { + keytipManager.processTransitionInput({ key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] }); + expect(onEnterKeytipMode).toBeCalled(); + }); + + // Return Tests + it('Should call on exit keytip mode because we are going back in the root', () => { + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.root; + keytipManager.processTransitionInput({ key: 'Escape' }); + expect(onExitKeytipMode).toBeCalled(); + }); + + it('C`s Return func should be invoked and Current keytip pointer should return to equal root node', () => { + const onReturnC: jest.Mock = jest.fn(); + keytipManager.keytipTree.currentKeytip = { ...keytipManager.keytipTree.nodeMap[keytipIdC], onReturn: onReturnC }; + keytipManager.processTransitionInput({ key: 'Escape' }); + expect(keytipManager.keytipTree.currentKeytip).toEqual(keytipManager.keytipTree.root); + expect(onReturnC).toBeCalled(); + }); + + // Processing keys tests + it('Processing a leaf node should execute it`s onExecute func and trigger onExitKeytipMode', () => { + const onExecuteC: jest.Mock = jest.fn(); + keytipManager.keytipTree.nodeMap[keytipIdC] = { ...keytipManager.keytipTree.nodeMap[keytipIdC], onExecute: onExecuteC }; + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.root; + keytipManager.processInput('c'); + expect(onExecuteC).toBeCalled(); + expect(onExitKeytipMode).toBeCalled(); + expect(keytipManager.currentSequence.keys.length).toEqual(0); + }); + + it('Processing a node with two keys should save sequence and wait for second key', () => { + const onExecuteE2: jest.Mock = jest.fn(); + keytipManager.keytipTree.nodeMap[keytipIdE2] = { ...keytipManager.keytipTree.nodeMap[keytipIdE2], onExecute: onExecuteE2 }; + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.root; + keytipManager.processInput('e'); + // We are still waiting for second key + expect(keytipManager.currentSequence.keys.length).toEqual(1); + keytipManager.processInput('2'); + expect(onExecuteE2).toBeCalled(); + expect(keytipManager.currentSequence.keys.length).toEqual(0); + expect(onExitKeytipMode).toBeCalled(); + }); + + it('Processing a node with two keys should wait for second key and if not leaf make children visible', () => { + const onExecuteE1: jest.Mock = jest.fn(); + keytipManager.keytipTree.nodeMap[keytipIdE1] = { ...keytipManager.keytipTree.nodeMap[keytipIdE1], onExecute: onExecuteE1 }; + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.root; + keytipManager.processInput('e'); + // We are still waiting for second key + expect(keytipManager.currentSequence.keys.length).toEqual(1); + keytipManager.processInput('1'); + expect(onExecuteE1).toBeCalled(); + // There is no more buffer in the sequence + expect(keytipManager.currentSequence.keys.length).toEqual(0); + // Children keytips should be visible + let childrenKeytips = getKeytips(defaultKeytipLayer, keytipManager.keytipTree.currentKeytip.children); + for (let childrenKeytip of childrenKeytips) { + expect(childrenKeytip.visible).toEqual(true); + } + // We haven't exited keytip mode (current keytip is not undefined and is set to the matched keytip) + expect(keytipManager.keytipTree.currentKeytip).toEqual(keytipManager.keytipTree.nodeMap[keytipIdE1]); + }); + + it('Processing a node which is not leaf but its children are not in the DOM', () => { + const onExecuteB: jest.Mock = jest.fn(); + keytipManager.keytipTree.nodeMap[keytipIdB] = { ...keytipManager.keytipTree.nodeMap[keytipIdB], onExecute: onExecuteB }; + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.root; + keytipManager.processInput('b'); + // Node B' on execute should be called + expect(onExecuteB).toBeCalled(); + // There is no more buffer in the sequence + expect(keytipManager.currentSequence.keys.length).toEqual(0); + // We haven't exited keytip mode (current keytip is not undefined and is set to the matched keytip) + expect(keytipManager.keytipTree.currentKeytip).toEqual(keytipManager.keytipTree.nodeMap[keytipIdB]); + }); + + it('Processing a node with with a keytipLink should make currentKeytip its link', () => { + const onExecuteOM: jest.Mock = jest.fn(); + let nodeOverflowM = keytipManager.keytipTree.nodeMap[keytipOverflowIdM]; + nodeOverflowM.onExecute = onExecuteOM; + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.root; + keytipManager.processInput('m'); + // Expect overflow m node's execute func to be + expect(onExecuteOM).toBeCalled(); + }); + }); + + describe('registerKeytip', () => { + + it('should automatically show Keytip when currentKeytip is its parent', () => { + // Create keytip b + const keytipSequenceB: IKeySequence[] = [{ keys: ['b'] }]; + const keytipBProps: IKeytipProps = { + keySequences: keytipSequenceB, + content: 'B' + }; + keytipManager.registerKeytip(keytipBProps); + + // Set currentKeytip to 'b' + keytipManager.keytipTree.currentKeytip = keytipManager.keytipTree.nodeMap[keytipIdB]; + + // Add a node 'g' who's parent is 'b' + const keytipSequenceG: IKeySequence[] = [{ keys: ['b'] }, { keys: ['g'] }]; + const keytipIdG = ktpFullPrefix + 'b' + ktpSeparator + 'g'; + const keytipGProps: IKeytipProps = { + keySequences: keytipSequenceG, + content: 'G' + }; + keytipManager.registerKeytip(keytipGProps); + + // G should now be visible in the layer + let keytipG = getKeytips(defaultKeytipLayer, [keytipIdG]); + expect(keytipG).toHaveLength(1); + expect(keytipG[0].visible).toEqual(true); + }); + + it('should update the keytip if it has the same key sequence', () => { + // Create keytip b + const keytipSequenceB: IKeySequence[] = [{ keys: ['b'] }]; + const keytipBProps: IKeytipProps = { + keySequences: keytipSequenceB, + content: 'B', + disabled: false + }; + keytipManager.registerKeytip(keytipBProps); + + let keytipB = getKeytips(defaultKeytipLayer, [keytipIdB]); + let keytipNodeB = keytipManager.keytipTree.nodeMap[keytipIdB]; + expect(keytipB).toHaveLength(1); + expect(keytipB[0].disabled).toEqual(false); + expect(keytipB[0].content).toEqual('B'); + expect(keytipNodeB.disabled).toEqual(false); + + // Change some properties + keytipBProps.disabled = true; + keytipBProps.content = 'BEE'; + + // Re-register + keytipManager.registerKeytip(keytipBProps); + + keytipB = getKeytips(defaultKeytipLayer, [keytipIdB]); + keytipNodeB = keytipManager.keytipTree.nodeMap[keytipIdB]; + expect(keytipB).toHaveLength(1); + expect(keytipB[0].disabled).toEqual(true); + expect(keytipB[0].content).toEqual('BEE'); + expect(keytipNodeB.disabled).toEqual(true); + }); + }); +}); + +function getKeytips(keytipLayer: ReactWrapper, keytipIDs?: string[]): IKeytipProps[] { + let currentKeytips: IKeytipProps[] = keytipLayer.state('keytips'); + if (keytipIDs) { + return currentKeytips.filter((currentKeytip: IKeytipProps) => { + return keytipIDs.indexOf(convertSequencesToKeytipID(currentKeytip.keySequences)) !== -1; + }); + } else { + return currentKeytips; + } +} + +function populateTreeMap(keytipTree: KeytipTree, rootId: string): KeytipTree { + /** + * Tree should end up looking like: + * + * a + * / / | \ \ \ + * b c e1 e2 o m + * / \ | + * d f m + * + */ + + const keytipSequenceB: IKeySequence = { keys: ['b'] }; + // Node C + const keytipSequenceC: IKeySequence = { keys: ['c'] }; + + // Node D + const keytipIdD = ktpFullPrefix + 'e' + ktpSeparator + '1' + ktpSeparator + 'd'; + const keytipSequenceD: IKeySequence = { keys: ['d'] }; + + // Node F + const keytipIdF = ktpFullPrefix + 'e' + ktpSeparator + '1' + ktpSeparator + 'f'; + const keytipSequenceF: IKeySequence = { keys: ['f'] }; + + // Node E1 + const keytipSequenceE1: IKeySequence = { keys: ['e', '1'] }; + + // Node E2 + const keytipSequenceE2: IKeySequence = { keys: ['e', '2'] }; + + // Node O + const keytipIdO = ktpFullPrefix + 'o'; + const keytipOverflowSeq: IKeySequence = { keys: ['o'] }; + + // Node M + const keytipIdM = ktpFullPrefix + 'm'; + const keytipSeqM: IKeySequence = { keys: ['m'] }; + + let nodeB = createTreeNode(keytipIdB, rootId, [], keytipSequenceB, true /* hasChildrenNodes*/); + let nodeC = createTreeNode(keytipIdC, rootId, [], keytipSequenceC); + let nodeE1 = createTreeNode(keytipIdE1, rootId, [keytipIdD, keytipIdF], keytipSequenceE1); + let nodeE2 = createTreeNode(keytipIdE2, rootId, [keytipIdD, keytipIdF], keytipSequenceE2); + let nodeD = createTreeNode(keytipIdD, keytipIdE1, [], keytipSequenceD); + let nodeF = createTreeNode(keytipIdF, keytipIdE1, [], keytipSequenceF); + let nodeO = createTreeNode(keytipIdO, rootId, [keytipOverflowIdM], keytipOverflowSeq, true); + let nodeOM = createTreeNode(keytipOverflowIdM, keytipIdO, [], keytipSeqM); + let nodeM = createTreeNode(keytipIdM, rootId, [], keytipSeqM); + nodeM.keytipLink = nodeOM; + keytipTree.nodeMap[rootId].children.push(keytipIdB, keytipIdC, keytipIdE1, keytipIdE2, keytipIdO, keytipIdM); + keytipTree.nodeMap[keytipIdB] = nodeB; + keytipTree.nodeMap[keytipIdC] = nodeC; + keytipTree.nodeMap[keytipIdE1] = nodeE1; + keytipTree.nodeMap[keytipIdE2] = nodeE2; + keytipTree.nodeMap[keytipIdD] = nodeD; + keytipTree.nodeMap[keytipIdF] = nodeF; + keytipTree.nodeMap[keytipIdO] = nodeO; + keytipTree.nodeMap[keytipOverflowIdM] = nodeOM; + keytipTree.nodeMap[keytipIdM] = nodeM; + return keytipTree; +} + +function createTreeNode(id: string, parentId: string, childrenIds: string[], + sequence: IKeySequence, hasChildren?: boolean): IKeytipTreeNode { + return { + id, + parent: parentId, + children: childrenIds, + keytipSequence: sequence, + hasChildrenNodes: hasChildren + }; +} \ No newline at end of file diff --git a/packages/experiments/src/utilities/keytip/KeytipManager.ts b/packages/experiments/src/utilities/keytip/KeytipManager.ts new file mode 100644 index 0000000000000..74d804118d371 --- /dev/null +++ b/packages/experiments/src/utilities/keytip/KeytipManager.ts @@ -0,0 +1,267 @@ +import { KeytipLayer } from '../../KeytipLayer'; +import { KeytipTree, IKeytipTreeNode } from './KeytipTree'; +import { IKeytipProps } from '../../Keytip'; +import { + IKeySequence, + convertSequencesToKeytipID, +} from '../../utilities/keysequence/IKeySequence'; +import { + transitionKeysContain, + IKeytipTransitionKey +} from '../../utilities/keysequence/IKeytipTransitionKey'; +import { constructKeytipTargetFromId } from '../../utilities/keytip/KeytipUtils'; + +export class KeytipManager { + private static _instance: KeytipManager = new KeytipManager(); + + public keytipTree: KeytipTree; + public currentSequence: IKeySequence; + + private _layer: KeytipLayer; + private _enableSequences: IKeytipTransitionKey[]; + private _exitSequences: IKeytipTransitionKey[]; + private _returnSequences: IKeytipTransitionKey[]; + + /** + * Static function to get singleton KeytipManager instance + * + * @returns {KeytipManager} Singleton KeytipManager instance + */ + public static getInstance(): KeytipManager { + return this._instance; + } + + /** + * Sets the _layer property and creates a KeytipTree + * Should be called from the KeytipLayer constructor + * + * @param layer - KeytipLayer object + */ + public init(layer: KeytipLayer): void { + this._layer = layer; + this.currentSequence = { keys: [] }; + this._enableSequences = this._layer.props.keytipStartSequences!; + this._exitSequences = this._layer.props.keytipExitSequences!; + this._returnSequences = this._layer.props.keytipReturnSequences!; + // Create the KeytipTree + this.keytipTree = new KeytipTree(this._layer.props.id); + } + + /** + * Gets the aria-describedby property for a set of keySequences + * keySequences should not include the initial keytip 'start' sequence + * + * @param keySequences - Full path of IKeySequences for one keytip + * @returns {string} String to use for the aria-describedby property for the element with this Keytip + */ + public getAriaDescribedBy(keySequences: IKeySequence[]): string { + let describedby = this._layer.props.id; + if (!keySequences.length) { + // Return just the layer ID + return describedby; + } + + return keySequences.reduce((prevValue: string, keySequence: IKeySequence, currentIndex: number): string => { + return prevValue + ' ' + convertSequencesToKeytipID(keySequences.slice(0, currentIndex + 1)); + }, describedby); + } + + /** + * Registers a keytip + * TODO: should this return an any? or something else + * + * @param keytipProps - Keytip to register + * @returns {any} Object containing the aria-describedby and data-ktp-id DOM properties + */ + // tslint:disable-next-line:no-any + public registerKeytip(keytipProps: IKeytipProps): any { + // Set 'visible' property to true in keytipProps if currentKeytip is this keytips parent + if (this._isCurrentKeytipParent(keytipProps)) { + keytipProps.visible = true; + } + + // Set the 'keytips' property in _layer + this._layer.registerKeytip(keytipProps); + this.keytipTree.addNode(keytipProps); + + // Construct aria-describedby and data-ktp-id attributes and return + let ariaDescribedBy = this.getAriaDescribedBy(keytipProps.keySequences); + let ktpId = convertSequencesToKeytipID(keytipProps.keySequences); + + return { + 'aria-describedby': ariaDescribedBy, + 'data-ktp-id': ktpId + }; + } + + /** + * Unregisters a keytip + * + * @param keytipToRemove - IKeytipProps of the keytip to remove + */ + public unregisterKeytip(keytipToRemove: IKeytipProps): void { + this._layer.unregisterKeytip(keytipToRemove); + this.keytipTree.removeNode(keytipToRemove.keySequences); + } + + /** + * Method that makes visible keytips currently in the DOM given a list of IDs. + * + * @param ids: list of Ids to show. + */ + public showKeytips(ids: string[]): void { + this._changeKeytipVisibility(ids, true /*visible*/); + } + + /** + * Method that hides keytips from the DOM given a list of IDs. + * If a list is not passed in, than it will hide all the currently registered keytips. + * + * @param ids: optional list of Ids to hide. + */ + public hideKeytips(ids?: string[]): void { + // We can either hide keytips from the supplied array of ids, or all keytips. + let keysToHide = ids ? ids : Object.keys(this.keytipTree.nodeMap); + this._changeKeytipVisibility(keysToHide, false /* visible */); + } + + public getLayerId(): string { + return this._layer.props.id; + } + + public exitKeytipMode(): void { + this.hideKeytips(); + this.keytipTree.currentKeytip = undefined; + this._layer.exitKeytipMode(); + } + + public enterKeytipMode(): void { + this._layer.enterKeytipMode(); + } + + /** + * Processes an IKeytipTransitionKey entered by the user + * + * @param transitionKey - IKeytipTransitionKey received by the layer to process + */ + public processTransitionInput(transitionKey: IKeytipTransitionKey): void { + if (transitionKeysContain(this._exitSequences, transitionKey) && this.keytipTree.currentKeytip) { + // If key sequence is in 'exit sequences', exit keytip mode + this.exitKeytipMode(); + } else if (transitionKeysContain(this._returnSequences, transitionKey)) { + // If key sequence is in return sequences, move currentKeytip to parent (or if currentKeytip is the root, exit) + if (this.keytipTree.currentKeytip) { + if (this.keytipTree.currentKeytip.id === this.keytipTree.root.id) { + // We are at the root, exit keytip mode + this.exitKeytipMode(); + } else { + // If this keytip has a onReturn prop, we execute the func. + if (this.keytipTree.currentKeytip.onReturn) { + let domEl = this._getKeytipDOMElement(this.keytipTree.currentKeytip.id); + this.keytipTree.currentKeytip.onReturn(domEl); + } + + // Clean currentSequence array + this.currentSequence.keys = []; + // Return pointer to its parent + this.keytipTree.currentKeytip = this.keytipTree.nodeMap[this.keytipTree.currentKeytip.parent!]; + // Hide all keytips + this.hideKeytips(); + // Show children keytips of the new currentKeytip + this.showKeytips(this.keytipTree.currentKeytip.children); + } + } + } else if (transitionKeysContain(this._enableSequences, transitionKey) && !this.keytipTree.currentKeytip) { + // If key sequence is in 'entry sequences' and currentKeytip is null, set currentKeytip to root + this.keytipTree.currentKeytip = this.keytipTree.root; + this.hideKeytips(); + // Show children of root + this.showKeytips(this.keytipTree.currentKeytip.children); + // Trigger onEnter callback + this.enterKeytipMode(); + } + } + + /** + * Processes inputs from the document listener and traverse the keytip tree + * + * @param key - Key pressed by the user + */ + public processInput(key: string): void { + // Concat the input key with the current sequence + let currentSequence: IKeySequence = { keys: [...this.currentSequence.keys, ...[key]] }; + + // currentKeytip must be defined, otherwise we haven't entered keytip mode yet + if (this.keytipTree.currentKeytip) { + let node = this.keytipTree.getExactMatchedNode(currentSequence, this.keytipTree.currentKeytip); + if (node) { + // If this is a persisted keytip, then we use its keytipLink + this.keytipTree.currentKeytip = node.keytipLink ? node.keytipLink : node; + + // Execute this node's onExecute if defined + if (this.keytipTree.currentKeytip.onExecute) { + let domEl = this._getKeytipDOMElement(this.keytipTree.currentKeytip.id); + this.keytipTree.currentKeytip.onExecute(domEl); + } + + // To exit keytipMode after executing keytip we should check if currentKeytip has no children and + // if the node doesn't have children nodes. + if (this.keytipTree.currentKeytip.children.length === 0 && !this.keytipTree.currentKeytip.hasChildrenNodes) { + this.exitKeytipMode(); + } else { + // Show all children keytips + this.hideKeytips(); + this.showKeytips(this.keytipTree.currentKeytip.children); + } + + // Clear currentSequence + this.currentSequence = { keys: [] }; + return; + } + + let partialNodes = this.keytipTree.getPartiallyMatchedNodes(currentSequence, this.keytipTree.currentKeytip); + if (partialNodes.length > 0) { + // We found nodes that partially match the sequence, so we show only those. + this.hideKeytips(); + let ids = partialNodes.map((partialNode: IKeytipTreeNode) => { return partialNode.id; }); + this.showKeytips(ids); + // Save currentSequence + this.currentSequence = currentSequence; + } + } + } + + private _changeKeytipVisibility(ids: string[], visible: boolean): void { + // Change visibility in layer + this._layer.setKeytipVisibility(ids, visible); + } + + /** + * Tests if the currentKeytip in this.keytipTree is the parent of 'keytipProps' + * + * @param keytipProps - Keytip to test the parent for + * @returns {boolean} T/F if the currentKeytip is this keytipProps' parent + */ + private _isCurrentKeytipParent(keytipProps: IKeytipProps): boolean { + if (this.keytipTree.currentKeytip) { + let fullSequence = [...keytipProps.keySequences]; + // Take off the last sequence to calculate the parent ID + fullSequence.pop(); + // Parent ID is the root if there aren't any more sequences + let parentID = fullSequence.length === 0 ? this.keytipTree.root.id : convertSequencesToKeytipID(fullSequence); + return this.keytipTree.currentKeytip.id === parentID; + } + return false; + } + + /** + * Gets the DOM element for the specified keytip + * + * @param keytipId - ID of the keytip to query for + * @return {HTMLElement} DOM element of the keytip + */ + private _getKeytipDOMElement(keytipId: string): HTMLElement { + let dataKeytipId = constructKeytipTargetFromId(keytipId); + return document.querySelector(dataKeytipId) as HTMLElement; + } +} \ No newline at end of file diff --git a/packages/experiments/src/utilities/keytip/KeytipTree.test.tsx b/packages/experiments/src/utilities/keytip/KeytipTree.test.tsx new file mode 100644 index 0000000000000..3fa2d92a72578 --- /dev/null +++ b/packages/experiments/src/utilities/keytip/KeytipTree.test.tsx @@ -0,0 +1,652 @@ +import * as React from 'react'; + +import * as ReactTestUtils from 'react-dom/test-utils'; +import { IKeytipProps, KeytipTransitionModifier } from '../../Keytip'; +import { KeytipTree, IKeytipTreeNode } from './KeytipTree'; +import { KeytipLayer } from '../../KeytipLayer'; +import { KeytipManager } from './KeytipManager'; +import { IKeySequence } from '../../utilities/keysequence/IKeySequence'; +import { IKeytipTransitionKey } from '../../utilities/keysequence/IKeytipTransitionKey'; +import { ktpSeparator, ktpFullPrefix } from '../../utilities/keytip/KeytipUtils'; + +describe('KeytipTree', () => { + const layerID = 'my-layer-id'; + const keytipStartSequences: IKeytipTransitionKey[] = [{ key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] }]; + const keytipExitSequences: IKeytipTransitionKey[] = [{ key: 'Meta', modifierKeys: [KeytipTransitionModifier.alt] }]; + const keytipReturnSequences: IKeytipTransitionKey[] = [{ key: 'Escape' }]; + const keytipManager = KeytipManager.getInstance(); + + beforeEach(() => { + // Create layer + ReactTestUtils.renderIntoDocument( + + ); + }); + + it('constructor creates a root node', () => { + let keytipTree = keytipManager.keytipTree; + + // Tree root ID should be the layer's ID + expect(keytipTree.root.id).toEqual(layerID); + // Tree root should not have any children + expect(keytipTree.root.children).toHaveLength(0); + + // Only the root should be specified in the nodeMap + expect(keytipTree.nodeMap[layerID]).toBeDefined(); + expect(Object.keys(keytipTree.nodeMap)).toHaveLength(1); + }); + + describe('addNode', () => { + it('directly under root works correctly', () => { + let keytipTree = keytipManager.keytipTree; + + // TreeNode C, will be child of root + const keytipIdC = ktpFullPrefix + 'c'; + const sampleKeySequence: IKeySequence[] = [{ keys: ['c'] }]; + + keytipTree.addNode(createKeytipProps(sampleKeySequence)); + + // Test C has been added to root's children + expect(keytipTree.root.children).toHaveLength(1); + expect(keytipTree.root.children).toContain(keytipIdC); + + // Test C was added to nodeMap + expect(Object.keys(keytipTree.nodeMap)).toHaveLength(2); + let keytipNodeC = keytipTree.nodeMap[keytipIdC]; + expect(keytipNodeC).toBeDefined(); + + // Test TreeNode C properties + expect(keytipNodeC.id).toEqual(keytipIdC); + expect(keytipNodeC.children).toHaveLength(0); + expect(keytipNodeC.parent).toEqual(layerID); + }); + + it('two levels from root', () => { + let keytipTree = keytipManager.keytipTree; + + // Parent + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence[] = [{ keys: ['c'] }]; + + const keytipIdB = ktpFullPrefix + 'c' + ktpSeparator + 'b'; + const keytipSequenceB: IKeySequence[] = [{ keys: ['c'] }, { keys: ['b'] }]; + + keytipTree.addNode(createKeytipProps(keytipSequenceC)); + keytipTree.addNode(createKeytipProps(keytipSequenceB)); + + // Test B was added to C's children + expect(keytipTree.nodeMap[keytipIdC].children).toHaveLength(1); + expect(keytipTree.nodeMap[keytipIdC].children).toContain(keytipIdB); + + // Test B was added to nodeMap + let keytipNodeB = keytipTree.nodeMap[keytipIdB]; + expect(keytipNodeB).toBeDefined(); + + // Test TreeNode B properties + expect(keytipNodeB.id).toEqual(keytipIdB); + expect(keytipNodeB.children).toHaveLength(0); + expect(keytipNodeB.parent).toEqual(keytipIdC); + }); + + it('add a child node before its parent', () => { + let keytipTree = keytipManager.keytipTree; + + // Parent + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence[] = [{ keys: ['c'] }]; + + // Child + const keytipIdB = ktpFullPrefix + 'c' + ktpSeparator + 'b'; + const keytipSequenceB: IKeySequence[] = [{ keys: ['c'] }, { keys: ['b'] }]; + + keytipTree.addNode(createKeytipProps(keytipSequenceB)); + + // Test B was added to nodeMap + let keytipNodeB = keytipTree.nodeMap[keytipIdB]; + expect(keytipNodeB).toBeDefined(); + + // Test B has C set as parent + expect(keytipNodeB.parent).toEqual(keytipIdC); + + // Test root still has no children + expect(keytipTree.root.children).toHaveLength(0); + + // Test C is added to nodeMap + let keytipNodeC = keytipTree.nodeMap[keytipIdC]; + expect(keytipNodeC).toBeDefined(); + + // Test C has no parent + expect(keytipNodeC.parent).toBeFalsy(); + + // Test C has B as its child + expect(keytipTree.nodeMap[keytipIdC].children).toHaveLength(1); + expect(keytipTree.nodeMap[keytipIdC].children).toContain(keytipIdB); + + // Add parent + keytipTree.addNode(createKeytipProps(keytipSequenceC)); + + keytipNodeC = keytipTree.nodeMap[keytipIdC]; + expect(keytipNodeC).toBeDefined(); + + // Test C has B as its child + expect(keytipTree.nodeMap[keytipIdC].children).toHaveLength(1); + expect(keytipTree.nodeMap[keytipIdC].children).toContain(keytipIdB); + + // Test root has C as its child + expect(keytipTree.root.children).toHaveLength(1); + expect(keytipTree.root.children).toContain(keytipIdC); + }); + + it('creates a correct Tree when many nodes are added out of order', () => { + /** + * Tree should end up looking like: + * + * a + * / \ + * c e + * / / \ + * b d f + * + * Nodes will be added in order: F, C, B, D, E + */ + + let keytipTree = keytipManager.keytipTree; + + // Node B + const keytipIdB = ktpFullPrefix + 'c' + ktpSeparator + 'b'; + const keytipSequenceB: IKeySequence[] = [{ keys: ['c'] }, { keys: ['b'] }]; + + // Node C + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence[] = [{ keys: ['c'] }]; + + // Node D + const keytipIdD = ktpFullPrefix + 'e' + ktpSeparator + 'd'; + const keytipSequenceD: IKeySequence[] = [{ keys: ['e'] }, { keys: ['d'] }]; + + // Node E + const keytipIdE = ktpFullPrefix + 'e'; + const keytipSequenceE: IKeySequence[] = [{ keys: ['e'] }]; + + // Node F + const keytipIdF = ktpFullPrefix + 'e' + ktpSeparator + 'f'; + const keytipSequenceF: IKeySequence[] = [{ keys: ['e'] }, { keys: ['f'] }]; + + keytipTree.addNode(createKeytipProps(keytipSequenceF)); + keytipTree.addNode(createKeytipProps(keytipSequenceC)); + keytipTree.addNode(createKeytipProps(keytipSequenceB)); + keytipTree.addNode(createKeytipProps(keytipSequenceD)); + keytipTree.addNode(createKeytipProps(keytipSequenceE)); + + // Test all nodes are in the nodeMap + let keytipNodeB = keytipTree.nodeMap[keytipIdB]; + expect(keytipNodeB).toBeDefined(); + let keytipNodeC = keytipTree.nodeMap[keytipIdC]; + expect(keytipNodeC).toBeDefined(); + let keytipNodeD = keytipTree.nodeMap[keytipIdD]; + expect(keytipNodeD).toBeDefined(); + let keytipNodeE = keytipTree.nodeMap[keytipIdE]; + expect(keytipNodeE).toBeDefined(); + let keytipNodeF = keytipTree.nodeMap[keytipIdF]; + expect(keytipNodeF).toBeDefined(); + + // Test each node's parent and children + expect(keytipNodeB.parent).toEqual(keytipIdC); + expect(keytipNodeB.children).toHaveLength(0); + + expect(keytipNodeC.parent).toEqual(layerID); + expect(keytipNodeC.children).toHaveLength(1); + expect(keytipNodeC.children).toContain(keytipIdB); + + expect(keytipNodeD.parent).toEqual(keytipIdE); + expect(keytipNodeD.children).toHaveLength(0); + + expect(keytipNodeE.parent).toEqual(layerID); + expect(keytipNodeE.children).toHaveLength(2); + expect(keytipNodeE.children).toContain(keytipIdD); + expect(keytipNodeE.children).toContain(keytipIdF); + + expect(keytipNodeF.parent).toEqual(keytipIdE); + expect(keytipNodeF.children).toHaveLength(0); + + // Test root's children + expect(keytipTree.root.children).toHaveLength(2); + expect(keytipTree.root.children).toContain(keytipIdC); + expect(keytipTree.root.children).toContain(keytipIdE); + }); + + it('add a node with overflowsetSequence when overflow node already has been created', () => { + let rootId = 'a'; + let keytipTree = new KeytipTree(rootId); + /** + * Tree should end up looking like: Where O is overflow menu and d is inside + * + * a + * / | + * o d + * | + * d + * + */ + + const keytipIdO = ktpFullPrefix + 'o'; + const overflowSequence: IKeySequence = { keys: ['o'] }; + keytipTree.addNode({ keySequences: [overflowSequence], content: '' }); + + const keytipPersistedIdD = ktpFullPrefix + 'd'; + const keytipOverflowIdD = ktpFullPrefix + 'o' + ktpSeparator + 'd'; + const keytipSequenceD: IKeySequence[] = [{ keys: ['d'] }]; + let keytipProps = createKeytipProps(keytipSequenceD, overflowSequence); + + // Add d node with an overflow sequence + keytipTree.addNode(keytipProps); + + // Root should have overflow keytip node and persisted keytip node. + expect(keytipTree.root.children).toHaveLength(2); + + // Test nodes are in the node map + let keytipOverflowNode = keytipTree.nodeMap[keytipIdO]; + expect(keytipOverflowNode).toBeDefined(); + let keytipPersistedD = keytipTree.nodeMap[keytipPersistedIdD]; + expect(keytipPersistedD).toBeDefined(); + let keytipOverflowD = keytipTree.nodeMap[keytipOverflowIdD]; + expect(keytipOverflowD).toBeDefined(); + + // Test hierarchy + expect(keytipOverflowNode.parent).toEqual(rootId); + expect(keytipPersistedD.parent).toEqual(rootId); + expect(keytipOverflowD.parent).toEqual(keytipIdO); + + // Persisted keytip keytip link should be the node in the overflow + expect(keytipPersistedD.keytipLink).toEqual(keytipOverflowD); + }); + + it('add a node with overflowsetSequence when overflow node has not been created', () => { + let rootId = 'a'; + let keytipTree = new KeytipTree(rootId); + /** + * Tree should end up looking like: Where O is overflow menu and d is inside + * + * a + * / | + * o d + * | + * d + * + */ + + const keytipIdO = ktpFullPrefix + 'o'; + const overflowSequence: IKeySequence = { keys: ['o'] }; + + const keytipPersistedIdD = ktpFullPrefix + 'd'; + const keytipOverflowIdD = ktpFullPrefix + 'o' + ktpSeparator + 'd'; + const keytipSequenceD: IKeySequence[] = [{ keys: ['d'] }]; + let keytipProps = createKeytipProps(keytipSequenceD, overflowSequence); + + // Add d node with an overflow sequence + keytipTree.addNode(keytipProps); + + // Root should have overflow keytip node and persisted keytip node. + expect(keytipTree.root.children).toHaveLength(2); + + // Test nodes are in the node map + let keytipOverflowNode = keytipTree.nodeMap[keytipIdO]; + expect(keytipOverflowNode).toBeDefined(); + let keytipPersistedD = keytipTree.nodeMap[keytipPersistedIdD]; + expect(keytipPersistedD).toBeDefined(); + let keytipOverflowD = keytipTree.nodeMap[keytipOverflowIdD]; + expect(keytipOverflowD).toBeDefined(); + + // Test hierarchy + expect(keytipOverflowNode.parent).toEqual(rootId); + expect(keytipPersistedD.parent).toEqual(rootId); + expect(keytipOverflowD.parent).toEqual(keytipIdO); + + // Persisted keytip keytip link should be the node in the overflow + expect(keytipPersistedD.keytipLink).toEqual(keytipOverflowD); + }); + }); + + describe('removeNode', () => { + it('removes a child node of root and has no children', () => { + let keytipTree = keytipManager.keytipTree; + + // Node C + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence[] = [{ keys: ['c'] }]; + + keytipTree.addNode(createKeytipProps(keytipSequenceC)); + + // Remove C from the tree + keytipTree.removeNode(keytipSequenceC); + + // Verify that C is not in the node map + expect(keytipTree.nodeMap[keytipIdC]).toBeUndefined(); + + // Verify that root has no children + expect(keytipTree.root.children).toHaveLength(0); + }); + + it('removes multiple nodes in order correctly', () => { + let keytipTree = keytipManager.keytipTree; + + // Node C + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence[] = [{ keys: ['c'] }]; + + // Node B + const keytipIdB = ktpFullPrefix + 'c' + ktpSeparator + 'b'; + const keytipSequenceB: IKeySequence[] = [{ keys: ['c'] }, { keys: ['b'] }]; + + keytipTree.addNode(createKeytipProps(keytipSequenceC)); + keytipTree.addNode(createKeytipProps(keytipSequenceB)); + + // Remove B + keytipTree.removeNode(keytipSequenceB); + + // Verify that B is not in the node map + expect(keytipTree.nodeMap[keytipIdB]).toBeUndefined(); + + // Verify C has no children + let nodeC = keytipTree.nodeMap[keytipIdC]; + expect(nodeC.children).toHaveLength(0); + + // Remove C + keytipTree.removeNode(keytipSequenceC); + + // Verify that C is not in the node map + expect(keytipTree.nodeMap[keytipIdC]).toBeUndefined(); + + // Verify that root has no children + expect(keytipTree.root.children).toHaveLength(0); + }); + + it('removes children as well when a parent is removed', () => { + let keytipTree = keytipManager.keytipTree; + + // Node C + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence[] = [{ keys: ['c'] }]; + + // Node B + const keytipIdB = ktpFullPrefix + 'c' + ktpSeparator + 'b'; + const keytipSequenceB: IKeySequence[] = [{ keys: ['c'] }, { keys: ['b'] }]; + + keytipTree.addNode(createKeytipProps(keytipSequenceC)); + keytipTree.addNode(createKeytipProps(keytipSequenceB)); + + // Remove C + keytipTree.removeNode(keytipSequenceC); + + // Verify that C is not in the node map + expect(keytipTree.nodeMap[keytipIdC]).toBeUndefined(); + // Verify that B is not in the node map + expect(keytipTree.nodeMap[keytipIdB]).toBeUndefined(); + + // Verify that root has no children + expect(keytipTree.root.children).toHaveLength(0); + }); + + it('Removing persisted keytip also removes overflow link node', () => { + let rootId = 'a'; + let keytipTree = new KeytipTree(rootId); + /** + * Tree should end up looking like: Where O is overflow menu and d is inside + * + * a + * / | + * o d + * | + * d + * + */ + + const keytipIdO = ktpFullPrefix + 'o'; + const overflowSequence: IKeySequence = { keys: ['o'] }; + keytipTree.addNode({ keySequences: [overflowSequence], content: '' }); + + const keytipSequenceD: IKeySequence[] = [{ keys: ['d'] }]; + let keytipProps = createKeytipProps(keytipSequenceD, overflowSequence); + + // Add d node with an overflow sequence + keytipTree.addNode(keytipProps); + + keytipTree.removeNode(keytipSequenceD); + expect(keytipTree.nodeMap[rootId].children).toHaveLength(1); + // Removing persisted d, should also remove o's children. + expect(keytipTree.nodeMap[keytipIdO].children).toHaveLength(0); + }); + }); + + describe('getExactlyMatchedNodes', () => { + it('get matched node tests ', () => { + let keytipTree = new KeytipTree('id1'); + + /** + * Tree should end up looking like: + * + * a + * / | \ + * c e1 e2 + * / \ + * d f + * + */ + + // Node C + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence = { keys: ['c'] }; + + // Node D + const keytipIdD = ktpFullPrefix + 'e' + ktpSeparator + '1' + ktpSeparator + 'd'; + const keytipSequenceD: IKeySequence = { keys: ['d'] }; + + // Node F + const keytipIdF = ktpFullPrefix + 'e' + ktpSeparator + '1' + ktpSeparator + 'f'; + const keytipSequenceF: IKeySequence = { keys: ['f'] }; + + // Node E1 + const keytipIdE1 = ktpFullPrefix + 'e' + ktpSeparator + '1'; + const keytipSequenceE1: IKeySequence = { keys: ['e', '1'] }; + + // Node E2 + const keytipIdE2 = ktpFullPrefix + 'e' + ktpSeparator + '2'; + const keytipSequenceE2: IKeySequence = { keys: ['e', '2'] }; + + // Node A + const keytipIdA = ktpFullPrefix + 'a'; + const keytipSequenceA: IKeySequence = { keys: ['a'] }; + + let nodeA = createTreeNode(keytipIdA, '', [keytipIdC, keytipIdE1, keytipIdE2], keytipSequenceA); + let nodeC = createTreeNode(keytipIdC, keytipIdA, [], keytipSequenceC); + let nodeE1 = createTreeNode(keytipIdE1, keytipIdA, [keytipIdD, keytipIdF], keytipSequenceE1); + let nodeE2 = createTreeNode(keytipIdE2, keytipIdA, [keytipIdD, keytipIdF], keytipSequenceE2); + let nodeD = createTreeNode(keytipIdD, keytipIdE1, [], keytipSequenceD); + let nodeF = createTreeNode(keytipIdF, keytipIdE1, [], keytipSequenceF); + + keytipTree.nodeMap[keytipIdA] = nodeA; + keytipTree.nodeMap[keytipIdC] = nodeC; + keytipTree.nodeMap[keytipIdE1] = nodeE1; + keytipTree.nodeMap[keytipIdE2] = nodeE2; + keytipTree.nodeMap[keytipIdD] = nodeD; + keytipTree.nodeMap[keytipIdF] = nodeF; + + // node should be undefined because it is not a child of node A. + let matchedNode1 = keytipTree.getExactMatchedNode({ keys: ['n'] }, nodeA); + expect(matchedNode1).toBeUndefined(); + + // node should be equal to node c due to keysequnce. + let matchedNode2 = keytipTree.getExactMatchedNode({ keys: ['c'] }, nodeA); + expect(matchedNode2).toEqual(nodeC); + }); + + it('should be undefined is matched node is disabled ', () => { + let keytipTree = new KeytipTree('id1'); + + /** + * Tree should end up looking like: + * + * a + * / | \ + * c e1 e2 + + * + */ + + // Node C + const keytipIdC = ktpFullPrefix + 'c'; + const keytipSequenceC: IKeySequence = { keys: ['c'] }; + + // Node E1 + const keytipIdE1 = ktpFullPrefix + 'e' + ktpSeparator + '1'; + const keytipSequenceE1: IKeySequence = { keys: ['e', '1'] }; + + // Node E2 + const keytipIdE2 = ktpFullPrefix + 'e' + ktpSeparator + '2'; + const keytipSequenceE2: IKeySequence = { keys: ['e', '2'] }; + + // Node A + const keytipIdA = ktpFullPrefix + 'a'; + const keytipSequenceA: IKeySequence = { keys: ['a'] }; + + let nodeA = createTreeNode(keytipIdA, '', [keytipIdC, keytipIdE1, keytipIdE2], keytipSequenceA); + let nodeC = createTreeNode(keytipIdC, keytipIdA, [], keytipSequenceC); + nodeC.disabled = true; + let nodeE1 = createTreeNode(keytipIdE1, keytipIdA, [], keytipSequenceE1); + let nodeE2 = createTreeNode(keytipIdE2, keytipIdA, [], keytipSequenceE2); + + keytipTree.nodeMap[keytipIdA] = nodeA; + keytipTree.nodeMap[keytipIdC] = nodeC; + keytipTree.nodeMap[keytipIdE1] = nodeE1; + keytipTree.nodeMap[keytipIdE2] = nodeE2; + + // node should be undefined because it is disabled. + let matchedNode1 = keytipTree.getExactMatchedNode({ keys: ['c'] }, nodeA); + expect(matchedNode1).toBeUndefined(); + }); + }); + + describe('getPartiallyMatchedNodes', () => { + it('get partially matched node tests ', () => { + let keytipTree = new KeytipTree('id1'); + + /** + * Tree should end up looking like: + * + * a + * | \ + * e1 e2 + * / \ + * d f + * + */ + + // Node D + const keytipIdD = ktpFullPrefix + 'e' + ktpSeparator + '1' + ktpSeparator + 'd'; + const keytipSequenceD: IKeySequence = { keys: ['d'] }; + + // Node F + const keytipIdF = ktpFullPrefix + 'e' + ktpSeparator + '1' + ktpSeparator + 'f'; + const keytipSequenceF: IKeySequence = { keys: ['f'] }; + + // Node E1 + const keytipIdE1 = ktpFullPrefix + 'e' + ktpSeparator + '1'; + const keytipSequenceE1: IKeySequence = { keys: ['e', '1'] }; + + // Node E2 + const keytipIdE2 = ktpFullPrefix + 'e' + ktpSeparator + '2'; + const keytipSequenceE2: IKeySequence = { keys: ['e', '2'] }; + + // Node A + const keytipIdA = ktpFullPrefix + 'a'; + const keytipSequenceA: IKeySequence = { keys: ['a'] }; + + let nodeA = createTreeNode(keytipIdA, '', [keytipIdE1, keytipIdE2], keytipSequenceA); + let nodeE1 = createTreeNode(keytipIdE1, keytipIdA, [keytipIdD, keytipIdF], keytipSequenceE1); + let nodeE2 = createTreeNode(keytipIdE2, keytipIdA, [keytipIdD, keytipIdF], keytipSequenceE2); + let nodeD = createTreeNode(keytipIdD, keytipIdE1, [], keytipSequenceD); + let nodeF = createTreeNode(keytipIdF, keytipIdE1, [], keytipSequenceF); + + keytipTree.nodeMap[keytipIdA] = nodeA; + keytipTree.nodeMap[keytipIdE1] = nodeE1; + keytipTree.nodeMap[keytipIdE2] = nodeE2; + keytipTree.nodeMap[keytipIdD] = nodeD; + keytipTree.nodeMap[keytipIdF] = nodeF; + + // nodes array should be empty. + let matchedNodes1 = keytipTree.getPartiallyMatchedNodes({ keys: ['n'] }, nodeA); + expect(matchedNodes1.length).toEqual(0); + + // nodes array should be empty. + let matchedNodes2 = keytipTree.getPartiallyMatchedNodes({ keys: [] }, nodeA); + expect(matchedNodes2.length).toEqual(0); + + // nodes array should be equal to 2. + let matchedNodes3 = keytipTree.getPartiallyMatchedNodes({ keys: ['e'] }, nodeA); + expect(matchedNodes3.length).toEqual(2); + }); + + it('get partially matched nodes that are not disabled ', () => { + let keytipTree = new KeytipTree('id1'); + + /** + * Tree should end up looking like: + * + * a + * | \ + * e1 e2 + * / \ + * d f + * + */ + + // Node E1 + const keytipIdE1 = ktpFullPrefix + 'e' + ktpSeparator + '1'; + const keytipSequenceE1: IKeySequence = { keys: ['e', '1'] }; + + // Node E2 + const keytipIdE2 = ktpFullPrefix + 'e' + ktpSeparator + '2'; + const keytipSequenceE2: IKeySequence = { keys: ['e', '2'] }; + + // Node A + const keytipIdA = ktpFullPrefix + 'a'; + const keytipSequenceA: IKeySequence = { keys: ['a'] }; + + let nodeA = createTreeNode(keytipIdA, '', [keytipIdE1, keytipIdE2], keytipSequenceA); + let nodeE1 = createTreeNode(keytipIdE1, keytipIdA, [], keytipSequenceE1); + let nodeE2 = createTreeNode(keytipIdE2, keytipIdA, [], keytipSequenceE2); + nodeE2.disabled = true; + + keytipTree.nodeMap[keytipIdA] = nodeA; + keytipTree.nodeMap[keytipIdE1] = nodeE1; + keytipTree.nodeMap[keytipIdE2] = nodeE2; + + // nodes array should equal 1, for node e2 is disabled. + let matchedNodes = keytipTree.getPartiallyMatchedNodes({ keys: ['e'] }, nodeA); + expect(matchedNodes.length).toEqual(1); + }); + }); +}); + +function createKeytipProps(keySequences: IKeySequence[], overflowSequence?: IKeySequence): IKeytipProps { + return { + keySequences, + overflowSetSequence: overflowSequence, + // Just add empty content since it's required, but not needed for tests + content: '' + }; +} + +function createTreeNode(id: string, parentId: string, childrenIds: string[], sequence: IKeySequence): IKeytipTreeNode { + return { + id, + parent: parentId, + children: childrenIds, + keytipSequence: sequence + }; +} \ No newline at end of file diff --git a/packages/experiments/src/utilities/keytip/KeytipTree.ts b/packages/experiments/src/utilities/keytip/KeytipTree.ts new file mode 100644 index 0000000000000..232f00d4e388e --- /dev/null +++ b/packages/experiments/src/utilities/keytip/KeytipTree.ts @@ -0,0 +1,294 @@ +import { + IKeySequence, + keySequencesAreEqual, + keySequenceStartsWith, + convertSequencesToKeytipID +} from '../../utilities/keysequence/IKeySequence'; +import { IKeytipProps } from 'src/Keytip'; +import { find } from '../../Utilities'; + +export interface IKeytipTreeNode { + /** + * ID of the DOM element. Needed to locate the correct keytip in the KeytipLayer's 'keytip' state array + */ + id: string; + + /** + * KeySequence that invokes this KeytipTreeNode's onExecute function + */ + keytipSequence: IKeySequence; + + /** + * Control's execute function for when keytip is invoked, passed from the component to the Manager in the IKeytipProps + */ + onExecute?: (el: HTMLElement) => void; + + /** + * Function to execute when we return to this keytip + */ + onReturn?: (el: HTMLElement) => void; + + /** + * List of keytip IDs that should become visible when this keytip is pressed, can be empty + */ + children: string[]; + + /** + * Parent keytip ID + */ + parent: string; + + /** + * Whether or not this node has children nodes or not. Should be used for menus/overflow components, that have + * their children registered after the initial rendering of the DOM. + */ + hasChildrenNodes?: boolean; + + /** + * T/F if this keytip's component is currently disabled + */ + disabled?: boolean; + + /** + * Link to another keytip node if this is a persisted keytip + */ + keytipLink?: IKeytipTreeNode; +} + +export interface IKeytipTreeNodeMap { + [nodeId: string]: IKeytipTreeNode; +} + +export class KeytipTree { + public currentKeytip?: IKeytipTreeNode; + public currentSequence: IKeySequence; + public root: IKeytipTreeNode; + public nodeMap: IKeytipTreeNodeMap = {}; + + /** + * KeytipTree constructor + * + * @param rootId - Layer ID to create the root node of the tree + */ + constructor(rootId: string) { + + // Root has no keytipSequence, we instead check _enableSequences to handle multiple entry points + this.root = { + id: rootId, + children: [], + parent: '', + keytipSequence: { keys: [] }, + hasChildrenNodes: true + }; + this.currentSequence = { keys: [] }; + this.nodeMap[this.root.id] = this.root; + } + + /** + * Add a keytip node to this KeytipTree + * + * @param keytipProps - Keytip to add to the Tree + */ + public addNode(keytipProps: IKeytipProps): void { + let fullSequence = [...keytipProps.keySequences]; + let nodeID = convertSequencesToKeytipID(fullSequence); + // This keytip's sequence is the last one defined + let keytipSequence = fullSequence.pop(); + // Parent ID is the root if there aren't any more sequences + let parentID = fullSequence.length === 0 ? this.root.id : convertSequencesToKeytipID(fullSequence); + + let overflowNode = undefined; + // Account for overflowSetSequence + if (keytipProps.overflowSetSequence && keytipSequence) { + let overflowParentNode = this._getOrCreateOverflowNode(keytipProps.overflowSetSequence, parentID, fullSequence); + let overflowNodeID = convertSequencesToKeytipID([...fullSequence, keytipProps.overflowSetSequence, keytipSequence]); + overflowNode = this.nodeMap[overflowNodeID]; + + // Create or update the persisted keytip + if (overflowNode) { + overflowNode.keytipSequence = keytipSequence; + overflowNode.onExecute = keytipProps.onExecute; + overflowNode.hasChildrenNodes = keytipProps.hasChildrenNodes; + overflowNode.parent = overflowParentNode.id; + overflowNode.disabled = keytipProps.disabled; + } else { + overflowNode = this._createNode(overflowNodeID, keytipSequence, overflowParentNode.id, [], keytipProps.hasChildrenNodes); + this.nodeMap[overflowNodeID] = overflowNode; + } + overflowParentNode.children.push(overflowNodeID); + } + + // See if node already exists + let node = this.nodeMap[nodeID]; + if (node) { + // If node exists, it was added when one of its children was added or is now being updated + // Update values + node.keytipSequence = keytipSequence!; + node.onExecute = keytipProps.onExecute; + node.onReturn = keytipProps.onReturn; + node.hasChildrenNodes = keytipProps.hasChildrenNodes; + node.keytipLink = overflowNode; + node.parent = parentID; + node.disabled = keytipProps.disabled; + } else { + // If node doesn't exist, add node + node = this._createNode(nodeID, keytipSequence!, parentID, [], keytipProps.hasChildrenNodes, + keytipProps.onExecute, keytipProps.onReturn, keytipProps.disabled); + node.keytipLink = overflowNode; + this.nodeMap[nodeID] = node; + } + + // Get parent node given its id. + let parent = this._getOrCreateParentNode(parentID); + + // Add node to parent's children if not already added + if (parent.children.indexOf(nodeID) === -1) { + parent.children.push(nodeID); + } + } + + /** + * Removes a node from the KeytipTree + * Will also remove all of the node's children from the Tree + * + * @param sequence - full IKeySequence of the node to remove + */ + public removeNode(sequence: IKeySequence[]): void { + let fullSequence = [...sequence]; + let nodeID = convertSequencesToKeytipID(fullSequence); + // Take off the last sequence to calculate the parent ID + fullSequence.pop(); + // Parent ID is the root if there aren't any more sequences + let parentID = fullSequence.length === 0 ? this.root.id : convertSequencesToKeytipID(fullSequence); + + let parent = this.nodeMap[parentID]; + if (parent) { + // Remove node from its parent's children + parent.children.splice(parent.children.indexOf(nodeID), 1); + } + + let node = this.nodeMap[nodeID]; + if (node) { + // Remove all the node's children from the nodeMap + let children = node.children; + for (let child of children) { + delete this.nodeMap[child]; + } + + // If node has an overflowLink, delete that node too. + let overflowLink = node.keytipLink; + if (overflowLink) { + let parentOverflow = this.nodeMap[overflowLink.parent]; + parentOverflow.children.splice(parentOverflow.children.indexOf(overflowLink.id, 1)); + delete this.nodeMap[overflowLink.id]; + } + + // Remove the node from the nodeMap + delete this.nodeMap[nodeID]; + } + } + + /** + * Searches the currentKeytip's children to exactly match a sequence + * + * @param keySequence - IKeySequence to match + * @param currentKeytip - The keytip who's children will try to match + * @returns {IKeytipTreeNode | undefined} The node that exactly matched the keySequence, or undefined if none matched + */ + public getExactMatchedNode(keySequence: IKeySequence, currentKeytip: IKeytipTreeNode): IKeytipTreeNode | undefined { + let possibleNodes = this._getNodes(currentKeytip.children); + + return find(possibleNodes, (node: IKeytipTreeNode) => { + return keySequencesAreEqual(node.keytipSequence, keySequence) && !node.disabled; + }); + } + + /** + * Searches the currentKeytip's children to find nodes that start with the given sequence + * + * @param keySequence - IKeySequence to partially match + * @param currentKeytip - The keytip who's children will try to partially match + * @returns {IKeytipTreeNode[]} List of tree nodes that partially match the given sequence + */ + public getPartiallyMatchedNodes(keySequence: IKeySequence, currentKeytip: IKeytipTreeNode): IKeytipTreeNode[] { + let possibleNodes = this._getNodes(currentKeytip.children); + + return possibleNodes.filter((node: IKeytipTreeNode) => { + return keySequenceStartsWith(node.keytipSequence, keySequence) && !node.disabled; + }); + } + + /** + * Retrieves or creates a parent node based on an ID + * + * @param parentId - ID of the parent node + * @returns {IKeytipTreeNode} Node retrieved or created from the given parent ID + */ + private _getOrCreateParentNode(parentId: string): IKeytipTreeNode { + let parent = this.nodeMap[parentId]; + if (!parent) { + // If parent doesn't exist, create parent with ID and children only + parent = this._createNode(parentId, { keys: [] }, '' /* parentId */, [] /* childrenIds */, true /*hasChildren */); + this.nodeMap[parentId] = parent; + } + return parent; + } + + private _createNode( + id: string, + sequence: IKeySequence, + parentId: string, + children: string[], + hasChildrenNodes?: boolean, + onExecute?: (el: HTMLElement) => void, + onReturn?: (el: HTMLElement) => void, + disabled?: boolean): IKeytipTreeNode { + return { + id, + keytipSequence: sequence, + parent: parentId, + children, + onExecute, + onReturn, + hasChildrenNodes, + disabled + }; + } + + /** + * Gets all nodes from their IDs + * + * @param ids List of keytip IDs + * @returns {IKeytipTreeNode[]} Array of nodes that match the given IDs + */ + private _getNodes(ids: string[]): IKeytipTreeNode[] { + return ids.map((id: string): IKeytipTreeNode => { + return this.nodeMap[id]; + }); + } + + /** + * Retrieves or creates an overflow node + * + * @param overflowSequence - Single key sequence for the overflow item + * @param parentId - ID of the parent keytip + * @param parentSequence - Full IKeySequence[] of the parent + * @returns {IKeytipTreeNode} - Node for this overflow item + */ + private _getOrCreateOverflowNode(overflowSequence: IKeySequence, parentId: string, parentSequence: IKeySequence[]): IKeytipTreeNode { + let fullOverflowSequence = [...parentSequence, ...[overflowSequence]]; + let overflowNodeId = convertSequencesToKeytipID(fullOverflowSequence); + + let node = this.nodeMap[overflowNodeId]; + + // if overflow node has not been added, we create it + if (!node) { + node = this._createNode(overflowNodeId, overflowSequence, parentId, [], true); + this.nodeMap[overflowNodeId] = node; + let parent = this._getOrCreateParentNode(parentId); + parent.children.push(overflowNodeId); + } + + return node; + } +} \ No newline at end of file diff --git a/packages/experiments/src/utilities/keytip/KeytipUtils.ts b/packages/experiments/src/utilities/keytip/KeytipUtils.ts new file mode 100644 index 0000000000000..141746cf7c62e --- /dev/null +++ b/packages/experiments/src/utilities/keytip/KeytipUtils.ts @@ -0,0 +1,60 @@ +import { IKeySequence, convertSequencesToKeytipID } from '../keysequence/IKeySequence'; +import { IKeytipProps } from '../../Keytip'; +import { KeytipManager } from './KeytipManager'; + +// Constants +export const ktpPrefix = 'ktp'; +export const ktpSeparator = '-'; +export const ktpFullPrefix = ktpPrefix + ktpSeparator; +export const dataKtpId = 'data-ktp-id'; + +/** + * Adds an IKeySequence to a list of sequences + * Returns a new array of IKeySequence + * + * @param sequences - Array of sequences to append to + * @param seq1 - IKeySequence to append + */ +export function addKeytipSequence(sequences: IKeySequence[], seq1: IKeySequence): IKeySequence[] { + return [...sequences, { keys: [...seq1.keys] }]; +} + +/** + * Utility funciton to register a keytip in the KeytipManager + * + * @param keytipProps - Keytip to register + * @returns - any {} containing the aria-describedby and data-ktp-id to add to the relevant element + */ +// tslint:disable-next-line:no-any +export function registerKeytip(keytipProps: IKeytipProps): any { + let ktpMgr = KeytipManager.getInstance(); + return ktpMgr.registerKeytip(keytipProps); +} + +/** + * Utility funciton to unregister a keytip in the KeytipManager + * + * @param keytipProps - Keytip to unregister + */ +export function unregisterKeytip(keytipProps: IKeytipProps): void { + let ktpMgr = KeytipManager.getInstance(); + ktpMgr.unregisterKeytip(keytipProps); +} + +/** + * Constructs the data-ktp-id attribute selector from a full key sequence + * + * @param keySequences - Full IKeySequence for a Keytip + */ +export function constructKeytipTargetFromSequences(keySequences: IKeySequence[]): string { + return '[' + dataKtpId + '="' + convertSequencesToKeytipID(keySequences) + '"]'; +} + +/** + * Constructs the data-ktp-id attribute selector from a keytip ID + * + * @param keytipId - ID of the Keytip + */ +export function constructKeytipTargetFromId(keytipId: string): string { + return '[' + dataKtpId + '="' + keytipId + '"]'; +} \ No newline at end of file diff --git a/packages/utilities/src/KeyCodes.ts b/packages/utilities/src/KeyCodes.ts index 1c84163588a27..08cbbd19d3297 100644 --- a/packages/utilities/src/KeyCodes.ts +++ b/packages/utilities/src/KeyCodes.ts @@ -4,22 +4,103 @@ * @public */ export const enum KeyCodes { - a = 65, - c = 67, backspace = 8, - comma = 188, - del = 46, - down = 40, - end = 35, + tab = 9, enter = 13, + shift = 16, + ctrl = 17, + alt = 18, + pauseBreak = 19, + capslock = 20, escape = 27, + space = 32, + pageUp = 33, + pageDown = 34, + end = 35, home = 36, left = 37, - pageDown = 34, - pageUp = 33, + up = 38, right = 39, + down = 40, + insert = 45, + del = 46, + zero = 48, + one = 49, + two = 50, + three = 51, + four = 52, + five = 53, + six = 54, + seven = 55, + eight = 56, + nine = 57, + a = 65, + b = 66, + c = 67, + d = 68, + e = 69, + f = 70, + g = 71, + h = 72, + i = 73, + j = 74, + k = 75, + l = 76, + m = 77, + n = 78, + o = 79, + p = 80, + q = 81, + r = 82, + s = 83, + t = 84, + u = 85, + v = 86, + w = 87, + x = 88, + y = 89, + z = 90, + leftWindow = 91, + rightWindow = 92, + select = 93, + zero_numpad = 96, + one_numpad = 97, + two_numpad = 98, + three_numpad = 99, + four_numpad = 100, + five_numpad = 101, + six_numpad = 102, + seven_numpad = 103, + eight_numpad = 104, + nine_numpad = 105, + multiply = 106, + add = 107, + subtract = 109, + decimalPoint = 110, + divide = 111, + f1 = 112, + f2 = 113, + f3 = 114, + f4 = 115, + f5 = 116, + f6 = 117, + f7 = 118, + f8 = 119, + f9 = 120, + f10 = 121, + f11 = 122, + f12 = 123, + numlock = 144, + scrollLock = 145, semicolon = 186, - space = 32, - tab = 9, - up = 38 + equalSign = 187, + comma = 188, + dash = 189, + period = 190, + forwardSlash = 191, + graveAccent = 192, + openBracket = 219, + backSlash = 220, + closeBracket = 221, + singleQuote = 222 } \ No newline at end of file diff --git a/scripts/tasks/jest-mock.js b/scripts/tasks/jest-mock.js index dce535c5c08d4..2c5ba89b2d1a6 100644 --- a/scripts/tasks/jest-mock.js +++ b/scripts/tasks/jest-mock.js @@ -1,23 +1,105 @@ const KeyCodes = { - a: 65, backspace: 8, - comma: 188, - del: 46, - down: 40, - end: 35, + tab: 9, enter: 13, + shift: 16, + ctrl: 17, + alt: 18, + pauseBreak: 19, + capslock: 20, escape: 27, + space: 32, + pageUp: 33, + pageDown: 34, + end: 35, home: 36, left: 37, - pageDown: 34, - pageUp: 33, + up: 38, right: 39, + down: 40, + insert: 45, + del: 46, + zero: 48, + one: 49, + two: 50, + three: 51, + four: 52, + five: 53, + six: 54, + seven: 55, + eight: 56, + nine: 57, + a: 65, + b: 66, + c: 67, + d: 68, + e: 69, + f: 70, + g: 71, + h: 72, + i: 73, + j: 74, + k: 75, + l: 76, + m: 77, + n: 78, + o: 79, + p: 80, + q: 81, + r: 82, + s: 83, + t: 84, + u: 85, + v: 86, + w: 87, + x: 88, + y: 89, + z: 90, + leftWindow: 91, + rightWindow: 92, + select: 93, + zero_numpad: 96, + one_numpad: 97, + two_numpad: 98, + three_numpad: 99, + four_numpad: 100, + five_numpad: 101, + six_numpad: 102, + seven_numpad: 103, + eight_numpad: 104, + nine_numpad: 105, + multiply: 106, + add: 107, + subtract: 109, + decimalPoint: 110, + divide: 111, + f1: 112, + f2: 113, + f3: 114, + f4: 115, + f5: 116, + f6: 117, + f7: 118, + f8: 119, + f9: 120, + f10: 121, + f11: 122, + f12: 123, + numlock: 144, + scrollLock: 145, semicolon: 186, - space: 32, - tab: 9, - up: 38, + equalSign: 187, + comma: 188, + dash: 189, + period: 190, + forwardSlash: 191, + graveAccent: 192, + openBracket: 219, + backSlash: 220, + closeBracket: 221, + singleQuote: 222 }; module.exports = { KeyCodes -}; +}; \ No newline at end of file diff --git a/scripts/tasks/jest-resources.js b/scripts/tasks/jest-resources.js index bd08e5432b443..6dbee44865c50 100644 --- a/scripts/tasks/jest-resources.js +++ b/scripts/tasks/jest-resources.js @@ -51,4 +51,4 @@ const styleMockPath = } }, customConfig) - }; + }; \ No newline at end of file