diff --git a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/constants/ScreenReaderMessage.ts b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/constants/ScreenReaderMessage.ts index ea96d76b2c..87fd4bf7d9 100644 --- a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/constants/ScreenReaderMessage.ts +++ b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/constants/ScreenReaderMessage.ts @@ -4,6 +4,8 @@ export enum ScreenReaderMessage { EventFocused = 'Event focused', ActionFocused = 'Action focused', + ActionUnfocused = 'Action unfocused', + RangeSelection = 'Range Selection', DialogOpened = 'Dialog opened', ActionDeleted = 'Action deleted', ActionsDeleted = 'Actions deleted', diff --git a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/contexts/SelectionContext.ts b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/contexts/SelectionContext.ts index 861ba45696..a7b576af26 100644 --- a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/contexts/SelectionContext.ts +++ b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/contexts/SelectionContext.ts @@ -7,6 +7,7 @@ import { SelectorElement } from '../utils/cursorTracker'; export interface SelectionContextData { getNodeIndex: (id: string) => number; + getSelectableIds: () => string[]; selectedIds: string[]; setSelectedIds: (ids: string[]) => any; selectableElements: SelectorElement[]; @@ -14,6 +15,7 @@ export interface SelectionContextData { export const SelectionContext = React.createContext({ getNodeIndex: (_: string): number => 0, + getSelectableIds: () => [], selectedIds: [] as string[], setSelectedIds: () => null, selectableElements: [], diff --git a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts index e53a4220a3..de87dba0b5 100644 --- a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts +++ b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts @@ -16,6 +16,7 @@ import { moveCursor } from '../utils/cursorTracker'; import { AttrNames } from '../constants/ElementAttributes'; import { NodeRendererContextValue } from '../contexts/NodeRendererContext'; import { SelectionContextData } from '../contexts/SelectionContext'; +import { calculateRangeSelection } from '../utils/calculateRangeSelection'; export const useEditorEventApi = ( state: { path: string; data: any; nodeContext: NodeRendererContextValue; selectionContext: SelectionContextData }, @@ -80,6 +81,47 @@ export const useEditorEventApi = ( announce(ScreenReaderMessage.ActionFocused); }; break; + case NodeEventTypes.CtrlClick: + handler = (e: { id: string; tab?: string }) => { + if (!focusedId && !selectedIds.length) { + return handleEditorEvent(NodeEventTypes.Focus, e); + } + + // Toggle the selection state of clicked id + const alreadySelected = selectedIds.some((x) => x === e.id); + if (alreadySelected) { + const shrinkedSelection = selectedIds.filter((x) => x !== e.id); + setSelectedIds(shrinkedSelection); + if (focusedId === e.id) { + onFocusSteps([shrinkedSelection[0] || '']); + } + announce(ScreenReaderMessage.ActionUnfocused); + } else { + const expandedSelection = [...selectedIds, e.id]; + setSelectedIds(expandedSelection); + onFocusSteps([e.id], e.tab); + announce(ScreenReaderMessage.ActionFocused); + } + }; + break; + case NodeEventTypes.ShiftClick: + handler = (e: { id: string; tab?: string }) => { + if (!focusedId && !selectedIds.length) { + return handleEditorEvent(NodeEventTypes.Focus, e); + } + + if (!focusedId) { + return handleEditorEvent(NodeEventTypes.CtrlClick, e); + } + + // Maintained by NodeIndexGenerator, `selectableIds` is in pre-order natively. + const selectableIds = selectionContext.getSelectableIds(); + // Range selection from 'focusedId' to Shift-Clicked id. + const newSelectedIds = calculateRangeSelection(focusedId, e.id, selectableIds); + setSelectedIds(newSelectedIds); + announce(ScreenReaderMessage.RangeSelection); + }; + break; case NodeEventTypes.FocusEvent: handler = (eventData) => { onFocusEvent(eventData); diff --git a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useSelectionEffect.ts b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useSelectionEffect.ts index a56e2c7196..613dd91169 100644 --- a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useSelectionEffect.ts +++ b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/hooks/useSelectionEffect.ts @@ -17,6 +17,7 @@ export const useSelectionEffect = (state: { data: any; nodeContext: NodeRenderer const [selectedIds, setSelectedIds] = useState([]); const [selectableElements, setSelectableElements] = useState(querySelectableElements()); const nodeIndexGenerator = useRef(new NodeIndexGenerator()); + const getSelectableIds = () => nodeIndexGenerator.current.getItemList().map((x) => x.key as string); useEffect((): void => { // Notify container at every selection change. @@ -60,5 +61,6 @@ export const useSelectionEffect = (state: { data: any; nodeContext: NodeRenderer setSelectedIds, selectableElements, getNodeIndex, + getSelectableIds, }; }; diff --git a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx index bd0c0fde20..3255718da8 100644 --- a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx +++ b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx @@ -58,10 +58,13 @@ export const ActionNodeWrapper: FC = ({ id, tab, data, onEvent addCoachMarkRef({ action }); }, []); + // Set 'use-select' to none to disable browser's default + // text selection effect when pressing Shift + Click. return (
= ({ id, tab, data, onEvent aria-label={generateSDKTitle(data, '', tab)} onClick={(e) => { e.stopPropagation(); - onEvent(NodeEventTypes.Focus, { id, tab }); + e.preventDefault(); + + const payload = { id, tab }; + if (e.ctrlKey || e.metaKey) { + return onEvent(NodeEventTypes.CtrlClick, payload); + } + if (e.shiftKey) { + return onEvent(NodeEventTypes.ShiftClick, payload); + } + onEvent(NodeEventTypes.Focus, payload); }} > {children} diff --git a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/utils/calculateRangeSelection.ts b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/utils/calculateRangeSelection.ts new file mode 100644 index 0000000000..dbbea65467 --- /dev/null +++ b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-editor/utils/calculateRangeSelection.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const calculateRangeSelection = ( + focusedId: string, + clickedId: string, + orderedSelectableIds: string[] +): string[] => { + const range = [focusedId, clickedId].map((id) => orderedSelectableIds.findIndex((x) => x === id)); + const [fromIndex, toIndex] = range.sort(); + return orderedSelectableIds.slice(fromIndex, toIndex + 1); +}; diff --git a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-renderer/constants/NodeEventTypes.ts b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-renderer/constants/NodeEventTypes.ts index f33407749d..795a295b70 100644 --- a/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-renderer/constants/NodeEventTypes.ts +++ b/Composer/packages/extensions/adaptive-flow/src/adaptive-flow-renderer/constants/NodeEventTypes.ts @@ -3,6 +3,8 @@ export enum NodeEventTypes { Focus = 'event.view.focus', + CtrlClick = 'event.view.ctrl-click', + ShiftClick = 'event.view.shift-click', FocusEvent = 'event.view.focus-event', MoveCursor = 'event.view.move-cursor', OpenDialog = 'event.nav.opendialog',