Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Original file line number Diff line number Diff line change
@@ -1,62 +1,96 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { ContextualMenu, DirectionalHint } from 'office-ui-fabric-react/lib/ContextualMenu';
import React, { useCallback, useMemo } from 'react';
import { builtInFunctionsGrouping, getBuiltInFunctionInsertText } from '@bfc/built-in-functions';

import { expressionGroupingsToMenuItems } from './utils/expressionsListMenuUtils';

const componentMaxHeight = 400;
import { DirectionalHint } from 'office-ui-fabric-react/lib/ContextualMenu';
import React from 'react';
import { Callout } from 'office-ui-fabric-react/lib/Callout';
import { FieldToolbar } from '@bfc/code-editor';
import { useShellApi } from '@bfc/extension-client';

type ExpressionsListMenuProps = {
onExpressionSelected: (expression: string) => void;
onMenuMount: (menuContainerElms: HTMLDivElement[]) => void;
container: HTMLDivElement | null;
Comment thread
tdurnford marked this conversation as resolved.
target: HTMLInputElement | HTMLTextAreaElement | null;
Comment thread
tdurnford marked this conversation as resolved.
clearTarget: () => void;
Comment thread
hatpick marked this conversation as resolved.
Outdated
value?: string;
onChange: (expression: string) => void;
};

export const ExpressionsListMenu = (props: ExpressionsListMenuProps) => {
const { onExpressionSelected, onMenuMount } = props;
const { clearTarget, container, target, value = '', onChange } = props;
const { projectId, shellApi } = useShellApi();

const containerRef = React.createRef<HTMLDivElement>();
const [memoryVariables, setMemoryVariables] = React.useState<string[] | undefined>();

const onExpressionKeySelected = useCallback(
(key) => {
const insertText = getBuiltInFunctionInsertText(key);
onExpressionSelected('= ' + insertText);
},
[onExpressionSelected]
);
React.useEffect(() => {
const abortController = new AbortController();
(async () => {
try {
const variables = await shellApi.getMemoryVariables(projectId, { signal: abortController.signal });
setMemoryVariables(variables);
} catch (e) {
// error can be due to abort
}
})();
}, [projectId]);

const onLayerMounted = useCallback(() => {
const elms = document.querySelectorAll<HTMLDivElement>('.ms-ContextualMenu-Callout');
onMenuMount(Array.prototype.slice.call(elms));
}, [onMenuMount]);

const menuItems = useMemo(
() =>
expressionGroupingsToMenuItems(
builtInFunctionsGrouping,
onExpressionKeySelected,
onLayerMounted,
componentMaxHeight
),
[onExpressionKeySelected, onLayerMounted]
);
React.useEffect(() => {
const keyDownHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
Comment thread
hatpick marked this conversation as resolved.
Outdated
clearTarget();
}
};

return (
<div ref={containerRef}>
<ContextualMenu
calloutProps={{
onLayerMounted: onLayerMounted,
}}
directionalHint={DirectionalHint.bottomLeftEdge}
hidden={false}
items={menuItems}
shouldFocusOnMount={false}
styles={{
container: { maxHeight: componentMaxHeight },
}}
target={containerRef}
/>
</div>
const focusHandler = (e: FocusEvent) => {
if (container?.contains(e.target as Node)) {
return;
}

if (
!e
.composedPath()
.filter((n) => n instanceof Element)
.map((n) => (n as Element).className)
.some((c) => c.indexOf('js-lg-toolbar-menu') !== -1)
Comment thread
hatpick marked this conversation as resolved.
Outdated
) {
clearTarget();
}
};

document.addEventListener('focusin', focusHandler);
document.addEventListener('keydown', keyDownHandler);

return () => {
document.removeEventListener('focusin', focusHandler);
document.removeEventListener('keydown', keyDownHandler);
};
}, [clearTarget, container]);

const onSelectToolbarMenuItem = React.useCallback(
(text: string) => {
if (typeof target?.selectionStart === 'number') {
const start = target.selectionStart;
const end = typeof target?.selectionEnd === 'number' ? target.selectionEnd : target.selectionStart;

const updatedItem = [value.slice(0, start), text, value.slice(end)].join('');
onChange(updatedItem);

setTimeout(() => {
target.setSelectionRange(updatedItem.length, updatedItem.length);
}, 0);
}

target?.focus();
},
[target, value, onChange]
);
return target ? (
<Callout directionalHint={DirectionalHint.topLeftEdge} gapSpace={2} isBeakVisible={false} target={target}>
<FieldToolbar
key="lg-toolbar"
Comment thread
tdurnford marked this conversation as resolved.
Outdated
excludedToolbarItems={['template']}
properties={memoryVariables}
onSelectToolbarMenuItem={onSelectToolbarMenuItem}
/>
</Callout>
) : null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,25 @@ export const IntellisenseExpressionField: React.FC<FieldProps<string>> = (props)
const scopes = ['expressions', 'user-variables'];
const intellisenseServerUrlRef = useRef(getIntellisenseUrl());

const [expressionsListContainerElements, setExpressionsListContainerElements] = useState<HTMLDivElement[]>([]);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [target, setTarget] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null);

const focus = React.useCallback(
(id: string, value?: string, event?: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (event?.target) {
event.stopPropagation();
setTarget(event.target as HTMLInputElement | HTMLTextAreaElement);
}
},
[]
);

const completionListOverrideResolver = (value: string) => {
return value === '=' ? (
<ExpressionsListMenu
onExpressionSelected={(expression: string) => onChange(expression)}
onMenuMount={(refs) => {
setExpressionsListContainerElements(refs);
}}
/>
) : null;
};
const clearTarget = React.useCallback(() => {
setTarget(null);
}, []);

return (
<Intellisense
completionListOverrideContainerElements={expressionsListContainerElements}
completionListOverrideResolver={completionListOverrideResolver}
focused={defaultFocused}
id={`intellisense-${id}`}
scopes={scopes}
Expand All @@ -102,18 +104,28 @@ export const IntellisenseExpressionField: React.FC<FieldProps<string>> = (props)
onKeyUpTextField,
onClickTextField,
}) => (
<StringField
{...props}
cursorPosition={cursorPosition}
focused={focused}
id={id}
value={textFieldValue}
onBlur={noop} // onBlur managed by Intellisense
onChange={(newValue) => onValueChanged(newValue || '')}
onClick={onClickTextField}
onKeyDown={onKeyDownTextField}
onKeyUp={onKeyUpTextField}
/>
<div ref={(ref) => setContainer(ref)}>
Comment thread
hatpick marked this conversation as resolved.
Outdated
<StringField
{...props}
cursorPosition={cursorPosition}
focused={focused}
id={id}
value={textFieldValue}
onBlur={noop} // onBlur managed by Intellisense
onChange={(newValue) => onValueChanged(newValue || '')}
onClick={onClickTextField}
onFocus={focus}
onKeyDown={onKeyDownTextField}
onKeyUp={onKeyUpTextField}
/>
<ExpressionsListMenu
clearTarget={clearTarget}
container={container}
target={target}
value={textFieldValue}
onChange={onChange}
/>
</div>
)}
</Intellisense>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const StringField: React.FC<FieldProps<string>> = function StringField(pr
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
if (typeof onFocus === 'function') {
e.stopPropagation();
onFocus(id, value);
onFocus(id, value, e);
}
};

Expand Down
2 changes: 1 addition & 1 deletion Composer/packages/extension-client/src/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface FieldProps<T = any> {
cursorPosition?: number;

onChange: ChangeHandler<T>;
onFocus?: (id: string, value?: T) => void;
onFocus?: (id: string, value?: T, event?: React.FocusEvent<any>) => void;
onBlur?: (id: string, value?: T) => void;

onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
Expand Down
11 changes: 4 additions & 7 deletions Composer/packages/intellisense/src/components/Intellisense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const Intellisense = React.memo(
if (didComplete.current) {
didComplete.current = false;
} else {
if (completionItems && completionItems.length) {
if (completionItems?.length) {
setShowCompletionList(true);
} else {
setShowCompletionList(false);
Expand All @@ -93,24 +93,21 @@ export const Intellisense = React.memo(
shouldBlur = false;
}

if (
completionListOverrideContainerElements &&
completionListOverrideContainerElements.some((item) => !checkIsOutside(x, y, item))
) {
if (completionListOverrideContainerElements?.some((item) => !checkIsOutside(x, y, item))) {
shouldBlur = false;
}

if (shouldBlur) {
setShowCompletionList(false);
setCursorPosition(-1);
onBlur && onBlur(id);
onBlur?.(id);
}
};

const keydownHandler = (event: KeyboardEvent) => {
if ((event.key === 'Escape' || event.key === 'Tab') && focused) {
setShowCompletionList(false);
onBlur && onBlur(id);
onBlur?.(id);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import { IContextualMenuProps } from 'office-ui-fabric-react/lib/ContextualMenu'
import * as React from 'react';
import { createSvgIcon } from '@fluentui/react-icons';

import { withTooltip } from '../utils/withTooltip';
import { withTooltip } from '../../utils/withTooltip';
import { jsLgToolbarMenuClassName } from '../../lg/constants';
import { ToolbarButtonPayload } from '../../types';
import { useEditorToolbarItems } from '../../hooks/useEditorToolbarItems';

import { jsLgToolbarMenuClassName } from './constants';
import { useLgEditorToolbarItems } from './hooks/useLgEditorToolbarItems';
import { ToolbarButtonMenu } from './ToolbarButtonMenu';
import { ToolbarButtonPayload } from './types';

const svgIconStyle = { fill: NeutralColors.black, margin: '0 4px', width: 16, height: 16 };

Expand Down Expand Up @@ -74,7 +74,8 @@ const configureMenuProps = (props: IContextualMenuProps | undefined, className:
return props;
};

export type LgEditorToolbarProps = {
export type FieldToolbarProps = {
excludedToolbarItems?: ToolbarButtonPayload['kind'][];
lgTemplates?: readonly LgTemplate[];
properties?: readonly string[];
onSelectToolbarMenuItem: (itemText: string, itemType: ToolbarButtonPayload['kind']) => void;
Expand All @@ -83,10 +84,18 @@ export type LgEditorToolbarProps = {
onPopExpand?: () => void;
};

export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => {
const { className, properties, lgTemplates, moreToolbarItems, onSelectToolbarMenuItem, onPopExpand } = props;

const { functionRefPayload, propertyRefPayload, templateRefPayload } = useLgEditorToolbarItems(
export const FieldToolbar = React.memo((props: FieldToolbarProps) => {
const {
className,
excludedToolbarItems,
properties,
lgTemplates,
moreToolbarItems,
onSelectToolbarMenuItem,
onPopExpand,
} = props;

const { functionRefPayload, propertyRefPayload, templateRefPayload } = useEditorToolbarItems(
lgTemplates ?? [],
properties ?? [],
onSelectToolbarMenuItem
Expand All @@ -106,8 +115,8 @@ export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => {
[]
);

const fixedItems: ICommandBarItemProps[] = React.useMemo(
() => [
const fixedItems: ICommandBarItemProps[] = React.useMemo(() => {
const items = [
{
key: 'template',
disabled: !templateRefPayload?.data?.templates?.length,
Expand All @@ -134,16 +143,18 @@ export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => {
key: 'function',
commandBarButtonAs: () => <TooltipFunctionButton key="function" payload={functionRefPayload} />,
},
],
[
TooltipTemplateButton,
TooltipPropertyButton,
TooltipFunctionButton,
templateRefPayload,
propertyRefPayload,
functionRefPayload,
]
);
];

return items.filter(({ key }) => !excludedToolbarItems?.includes(key as ToolbarButtonPayload['kind']));
}, [
TooltipTemplateButton,
TooltipPropertyButton,
TooltipFunctionButton,
templateRefPayload,
propertyRefPayload,
functionRefPayload,
excludedToolbarItems,
]);

const moreItems = React.useMemo(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Icon, IIconStyles } from 'office-ui-fabric-react/lib/Icon';
import { Stack } from 'office-ui-fabric-react/lib/Stack';
import * as React from 'react';

import { PropertyItem } from './types';
import { PropertyItem } from '../../types';

const DEFAULT_TREE_ITEM_HEIGHT = 36;
const DEFAULT_INDENTATION_PADDING = 16;
Expand Down
Loading