Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Update keytip functionality #20396

Merged
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
68fffc2
Make callouts aware of the WindowSegments
jspurlin Sep 5, 2019
bfd68c1
Revert "Make callouts aware of the WindowSegments"
jspurlin Sep 5, 2019
5d89c48
Merge branch 'master' of https://github.com/OfficeDev/office-ui-fabri…
jspurlin Sep 5, 2019
297ccfb
Merge branch 'master' of https://github.com/OfficeDev/office-ui-fabri…
jspurlin Sep 11, 2019
b0a90af
Merge branch 'master' of https://github.com/OfficeDev/office-ui-fabri…
jspurlin Sep 19, 2019
f84ee51
Merge branch 'master' of https://github.com/OfficeDev/office-ui-fabri…
jspurlin Jan 9, 2020
4a22530
Merge branch 'master' of http://github.com/microsoft/fluentui
jspurlin Jul 28, 2020
60042cd
Merge branch 'master' of http://github.com/microsoft/fluentui
jspurlin Oct 30, 2020
06343aa
Merge branch 'master' of https://github.com/microsoft/fluentui
jspurlin Jan 19, 2021
af21a19
Merge branch 'master' of http://github.com/microsoft/fluentui
jspurlin Aug 5, 2021
955cfc4
Merge branch 'microsoft:master' into master
jspurlin Sep 22, 2021
3843732
Merge branch 'master' of http://github.com/microsoft/fluentui
jspurlin Oct 26, 2021
2b16965
Keytips: Make keytips aware of the visibility of their targets
jspurlin Oct 28, 2021
a02945f
Change files
jspurlin Oct 28, 2021
fcc3b3e
Fix the lint errors
jspurlin Oct 28, 2021
6865f1f
Update keytip story
jspurlin Oct 28, 2021
debb6c9
Actually update the story to use a DefaultButton instead of an html b…
jspurlin Oct 28, 2021
ffdc5ad
actually I just noticed there was already a ktp target span, undoing …
jspurlin Oct 28, 2021
ee9d3c2
Update story because js was executing before first paint causing keyt…
jspurlin Oct 29, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/vr-tests/src/stories/Keytip.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { Keytip } from '@fluentui/react';

storiesOf('Keytip', module)
.addDecorator(story => (
<div style={{ width: '50px', height: '50px' }}>
<span data-ktp-target={'ktp-a'} />
<div style={{ width: '150px', height: '150px' }}>
<span data-ktp-target={'ktp-a'}>text</span>
{story()}
</div>
))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Keytips: Make keytips aware of the visibility of their targets",
"packageName": "@fluentui/react",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Add isElementVisibleAndNotHidden utility function",
"packageName": "@fluentui/utilities",
"email": "[email protected]",
"dependentChangeType": "patch"
}
1 change: 1 addition & 0 deletions packages/react/etc/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5555,6 +5555,7 @@ export interface IKeytipProps {
disabled?: boolean;
hasDynamicChildren?: boolean;
hasMenu?: boolean;
hasOverflowSubMenu?: boolean;
keySequences: string[];
offset?: Point;
onExecute?: (executeTarget: HTMLElement | null, target: HTMLElement | null) => void;
Expand Down
12 changes: 11 additions & 1 deletion packages/react/src/components/Keytip/Keytip.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as React from 'react';
import { getFirstVisibleElementFromSelector } from '../../Utilities';
import { mergeOverflows, ktpTargetFromSequences } from '../../utilities/keytips/KeytipUtils';
import { Callout } from '../../Callout';
import { DirectionalHint } from '../../ContextualMenu';
import { KeytipContent } from './KeytipContent';
import { getCalloutStyles, getCalloutOffsetStyles } from './Keytip.styles';
import type { IKeytipProps } from './Keytip.types';
import type { Target } from '@fluentui/react-hooks';

/**
* A callout corresponding to another Fabric component to describe a key sequence that will activate that component
Expand All @@ -14,14 +16,22 @@ export class Keytip extends React.Component<IKeytipProps, {}> {
const { keySequences, offset, overflowSetSequence } = this.props;
let { calloutProps } = this.props;

let keytipTarget: string;
let keytipTarget: Target;
// Take into consideration the overflow sequence
if (overflowSetSequence) {
keytipTarget = ktpTargetFromSequences(mergeOverflows(keySequences, overflowSetSequence));
} else {
keytipTarget = ktpTargetFromSequences(keySequences);
}

const element = getFirstVisibleElementFromSelector(keytipTarget);

if (!element) {
return <></>;
}

keytipTarget = element;

if (offset) {
// Set callout to top-left corner, will be further positioned in
// getCalloutOffsetStyles
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/components/Keytip/Keytip.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export interface IKeytipProps {
* Keytip mode will stay on when a menu is opened, even if the items in that menu have no keytips
*/
hasMenu?: boolean;

/**
* Whether or not this keytip belongs to a component that is in an overflow menu
* and also has a menu
*/
hasOverflowSubMenu?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,10 @@ export interface IKeytipTreeNode {
* T/F if this keytip is a persisted keytip
*/
persisted?: boolean;

/**
* Whether or not this keytip belongs to a component that is in an overflow menu
* and also has a menu
*/
hasOverflowSubMenu?: boolean;
}
10 changes: 10 additions & 0 deletions packages/react/src/components/KeytipLayer/KeytipLayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('KeytipLayer', () => {

const keytipIdC = KTP_FULL_PREFIX + 'c';
const uniqueIdC = '2';
const uniqueIdC2 = '22';
const keytipC: IKeytipProps = {
content: 'C',
keySequences: ['c'],
Expand Down Expand Up @@ -316,6 +317,15 @@ describe('KeytipLayer', () => {
// E2 should be triggered
expect(nodeE2.onExecute).toBeCalled();
});
it('Process a node with no matching visible element and is a submenu in an overflow', () => {
Comment on lines 319 to +320
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
});
it('Process a node with no matching visible element and is a submenu in an overflow', () => {
});
it('Process a node with no matching visible element and is a submenu in an overflow', () => {

// Make C2 a submenu in an overflow
const onExecuteC2: jest.Mock = jest.fn();
ktpTree.addNode({ ...keytipC, hasOverflowSubMenu: true, onExecute: onExecuteC2 }, uniqueIdC2);
ktpTree.currentKeytip = ktpTree.root;
layerValue.processInput('c');
// C2 should be triggered
expect(onExecuteC2).toBeCalled();
});
});
});

Expand Down
51 changes: 48 additions & 3 deletions packages/react/src/components/KeytipLayer/KeytipTree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { find, values } from '../../Utilities';
import { mergeOverflows, sequencesToID } from '../../utilities/keytips/KeytipUtils';
import { find, isElementVisibleAndNotHidden, values } from '../../Utilities';
import { ktpTargetFromSequences, mergeOverflows, sequencesToID } from '../../utilities/keytips/KeytipUtils';
import { KTP_LAYER_ID } from '../../utilities/keytips/KeytipConstants';
import type { IKeytipProps } from '../../Keytip';
import type { IKeytipTreeNode } from './IKeytipTreeNode';
Expand Down Expand Up @@ -126,9 +126,52 @@ export class KeytipTree {
*/
public getExactMatchedNode(keySequence: string, currentKeytip: IKeytipTreeNode): IKeytipTreeNode | undefined {
const possibleNodes = this.getNodes(currentKeytip.children);
return find(possibleNodes, (node: IKeytipTreeNode) => {
const matchingNodes = possibleNodes.filter((node: IKeytipTreeNode) => {
return this._getNodeSequence(node) === keySequence && !node.disabled;
});

// If we found no nodes, we are done
if (matchingNodes.length === 0) {
return undefined;
}

// Since the matching nodes all have the same key sequence,
// Grab the first one build the correct selector
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
// Grab the first one build the correct selector
// Grab the first one to build the correct selector

const node = matchingNodes[0];

// If we have exactly one node, return it
if (matchingNodes.length === 1) {
return node;
}

// Get the potential target elements based on a selector from the sequences
const keySequences = node.keySequences;
const overflowSetSequence = node.overflowSetSequence;
const fullKeySequences = overflowSetSequence ? mergeOverflows(keySequences, overflowSetSequence) : keySequences;
const keytipTargetSelector = ktpTargetFromSequences(fullKeySequences);
const potentialTargetElements = document.querySelectorAll(keytipTargetSelector);

// If we have less nodes than the potential target elements,
// we won't be able to map element to node, return the first node.
// Note, the number of nodes could be more than the number of potential
// target elements, if an OverflowSet is involved
if (matchingNodes.length < potentialTargetElements.length) {
return node;
}

// Attempt to find the node that corresponds to the first visible/non-hidden element
const matchingIndex = Array.from(potentialTargetElements).findIndex((element: HTMLElement) =>
isElementVisibleAndNotHidden(element),
);
if (matchingIndex !== -1) {
return matchingNodes[matchingIndex];
}

// We did not find any visible elements associated with any of the nodes.
// We may be dealing with a keytip that is a submenu in an OverflowSet.
// Worst case, fall back to the first node returned
const overflowNode = matchingNodes.find(matchingNode => matchingNode.hasOverflowSubMenu);
return overflowNode || node;
}

/**
Expand Down Expand Up @@ -259,6 +302,7 @@ export class KeytipTree {
onExecute,
onReturn,
disabled,
hasOverflowSubMenu,
} = keytipProps;
const node = {
id,
Expand All @@ -272,6 +316,7 @@ export class KeytipTree {
hasMenu,
disabled,
persisted,
hasOverflowSubMenu,
};
node.children = Object.keys(this.nodeMap).reduce((array: string[], nodeMapKey: string): string[] => {
if (this.nodeMap[nodeMapKey].parent === id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const OverflowButton = (props: IOverflowSetProps) => {
keytipSequences,
overflowItem?.keytipProps?.keySequences,
);
persistedKeytip.hasOverflowSubMenu = true;
} else {
// If the keytip doesn't have a submenu, just execute the original function
persistedKeytip.onExecute = keytip.onExecute;
Expand Down
6 changes: 6 additions & 0 deletions packages/utilities/etc/utilities.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ export function getFirstFocusable(rootElement: HTMLElement, currentElement: HTML
// @public
export function getFirstTabbable(rootElement: HTMLElement, currentElement: HTMLElement, includeElementsInFocusZones?: boolean, checkNode?: boolean): HTMLElement | null;

// @public
export function getFirstVisibleElementFromSelector(selector: string): Element | undefined;

// @public
export function getFocusableByIndexPath(parent: HTMLElement, path: number[]): HTMLElement | undefined;

Expand Down Expand Up @@ -765,6 +768,9 @@ export function isElementTabbable(element: HTMLElement, checkTabIndex?: boolean)
// @public
export function isElementVisible(element: HTMLElement | undefined | null): boolean;

// @public
export function isElementVisibleAndNotHidden(element: HTMLElement | undefined | null): boolean;

// Warning: (ae-internal-missing-underscore) The name "ISerializableObject" should be prefixed with an underscore because the declaration is marked as @internal
//
// @internal
Expand Down
1 change: 1 addition & 0 deletions packages/utilities/src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './dom/elementContainsAttribute';
export * from './dom/findElementRecursive';
export * from './dom/getChildren';
export * from './dom/getDocument';
export * from './dom/getFirstVisibleElementFromSelector';
export * from './dom/getParent';
export * from './dom/getRect';
export * from './dom/getVirtualParent';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { isElementVisibleAndNotHidden } from '../focus';
import { getDocument } from './getDocument';

/**
* Gets the first visible element that matches the given selector
* @param selector - The selector to use to find potential visible elements
* @returns The first visible element that matches the selector, otherwise undefined
*
* @public
*/
export function getFirstVisibleElementFromSelector(selector: string): Element | undefined {
const elements = getDocument()!.querySelectorAll(selector);

// Iterate across the elements that match the selector and return the first visible/non-hidden element
return Array.from(elements).find((element: HTMLElement) => isElementVisibleAndNotHidden(element));
}
16 changes: 16 additions & 0 deletions packages/utilities/src/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,22 @@ export function isElementVisible(element: HTMLElement | undefined | null): boole
); // used as a workaround for testing.
}

/**
* Determines if an element is visible and not hidden
* @param element - Element to check
* @returns Returns true if the given element is visible and not hidden
*
* @public
*/
export function isElementVisibleAndNotHidden(element: HTMLElement | undefined | null): boolean {
return (
!!element &&
isElementVisible(element) &&
!element.hidden &&
window.getComputedStyle(element).visibility !== 'hidden'
);
}

/**
* Determines if an element can receive focus programmatically or via a mouse click.
* If checkTabIndex is true, additionally checks to ensure the element can be focused with the tab key,
Expand Down