Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 103 additions & 0 deletions Composer/packages/client/src/components/CellFocusZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
/** @jsx jsx */
import { jsx, css } from '@emotion/core';
import React, { useCallback } from 'react';
import { FocusZone, FocusZoneTabbableElements, IFocusZoneProps } from 'office-ui-fabric-react/lib/FocusZone';
import { getFocusStyle, getTheme, mergeStyles } from 'office-ui-fabric-react/lib/Styling';

import { useAfterRender } from '../hooks/useAfterRender';

export const formCell = css`
outline: none;
white-space: pre-wrap;
font-size: 14px;
line-height: 28px;
`;

export const formCellFocus = mergeStyles(
getFocusStyle(getTheme(), {
inset: -3,
})
);

/**
* CellFocusZone component props.
* Props are intently omitted as they're required for component to function correctly.
* Please manually test the component using keyboard in places it is used in case any changes made.
*/
type CellFocusZoneProps = Omit<
React.HTMLAttributes<unknown> & IFocusZoneProps,
| 'allowFocusRoot'
| 'data-is-focusable'
| 'isCircularNavigation'
| 'className'
| 'handleTabKey'
| 'shouldFocusInnerElementWhenReceivedFocus'
| 'tabIndex'
| 'onBlur'
| 'onKeyDown'
>;

const CellFocusZone: React.FC<CellFocusZoneProps> = (props) => {
const onAfterRender = useAfterRender();
const focusCurrentZoneAfterRender = useCallback((focusZoneEl: any) => {
const focusZoneId = focusZoneEl?.dataset?.focuszoneId;
if (!focusZoneId) {
return;
}
// wait for render to happen before placing focus back to the focus zone
onAfterRender(() => {
const focusZone: HTMLElement | null = document.querySelector(`[data-focuszone-id=${focusZoneId}]`);
focusZone?.focus();
});
}, []);

const onCellKeyDown = useCallback((ev) => {
// ignore any of events coming from input fields
if (ev.target.localName === 'input' || ev.target.localName === 'textarea') {
ev.stopPropagation();
return;
}

// enter inside cell using Enter key
if (ev.target.dataset.focuszoneId && ev.key === 'Enter') {
const input: HTMLElement | null = (ev?.target as HTMLElement)?.querySelector('a, button, input, textarea');
input?.focus();
return;
}

// handle uncaught escape key events to allow returning back to navigation between cells
if (ev.key === 'Escape') {
ev.stopPropagation();
focusCurrentZoneAfterRender(ev.currentTarget);
}
}, []);

const onCellInnerBlur = useCallback((ev) => {
// this means we dont have an element to focus so we place focus back to the cell
if (!ev.relatedTarget) {
focusCurrentZoneAfterRender(ev.currentTarget);
}
}, []);

return (
<FocusZone
css={formCell}
{...props}
allowFocusRoot
data-is-focusable
isCircularNavigation
className={formCellFocus}
handleTabKey={FocusZoneTabbableElements.all}
shouldFocusInnerElementWhenReceivedFocus={false}
tabIndex={0}
onBlur={onCellInnerBlur}
onKeyDown={onCellKeyDown}
>
{props.children}
</FocusZone>
);
};

export { CellFocusZone };
33 changes: 29 additions & 4 deletions Composer/packages/client/src/components/EditableField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { NeutralColors, SharedColors } from '@uifabric/fluent-theme';
import { mergeStyleSets } from '@uifabric/styling';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import { IIconProps } from 'office-ui-fabric-react/lib/Icon';
import { Announced } from 'office-ui-fabric-react/lib/Announced';

import { FieldConfig, useForm } from '../hooks/useForm';
import { useAfterRender } from '../hooks/useAfterRender';

const allowedNavigationKeys = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'PageDown', 'PageUp', 'Home', 'End'];

Expand All @@ -24,10 +26,12 @@ const defaultContainerStyle = (hasFocus, hasErrors) => css`
: undefined};
background: ${hasFocus || hasErrors ? NeutralColors.white : 'inherit'};
margin-top: 2px;
:hover .ms-Button-icon {
:hover .ms-Button-icon,
:focus-within .ms-Button-icon {
visibility: visible;
}
.ms-TextField-field {
min-height: 35px;
cursor: pointer;
padding-left: ${hasFocus || hasErrors ? '8px' : '0px'};
:focus {
Expand Down Expand Up @@ -64,7 +68,6 @@ interface EditableFieldProps extends Omit<ITextFieldProps, 'onChange' | 'onFocus
styles?: Partial<ITextFieldStyles>;
transparentBorder?: boolean;
ariaLabel?: string;
error?: string | JSX.Element;
extraContent?: string;
containerStyles?: SerializedStyles;
className?: string;
Expand All @@ -81,9 +84,11 @@ interface EditableFieldProps extends Omit<ITextFieldProps, 'onChange' | 'onFocus
value?: string;
iconProps?: IconProps;
enableIcon?: boolean;
error?: string;
onBlur?: (id: string, value?: string) => void;
onChange: (newValue?: string) => void;
onFocus?: () => void;
onNewLine?: () => void;
}

const EditableField: React.FC<EditableFieldProps> = (props) => {
Expand All @@ -102,6 +107,7 @@ const EditableField: React.FC<EditableFieldProps> = (props) => {
onChange,
onFocus,
onBlur,
onNewLine,
value,
id,
error,
Expand All @@ -114,6 +120,7 @@ const EditableField: React.FC<EditableFieldProps> = (props) => {
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [hasBeenEdited, setHasBeenEdited] = useState<boolean>(false);
const [multiline, setMultiline] = useState<boolean>(true);
const onAfterRender = useAfterRender();

const formConfig: FieldConfig<{ value: string }> = {
value: {
Expand Down Expand Up @@ -192,13 +199,29 @@ const EditableField: React.FC<EditableFieldProps> = (props) => {
e.stopPropagation();
}
const enterOnField = e.key === 'Enter' && hasFocus;
if (enterOnField && !multiline) {
if (enterOnField && !multiline && e.shiftKey) {
e.stopPropagation();
if (onNewLine) {
onNewLine();
return;
}
setMultiline(true);
updateField('value', e.target.value + '\n');
// wait for the textarea to be rendered and then restore focus and selection
onAfterRender(() => {
const len = fieldRef.current?.value?.length;
fieldRef.current?.focus();
if (len) {
fieldRef.current?.setSelectionRange(len, len);
}
});
}
if (enterOnField && !e.shiftKey) {
handleCommit();
// blur triggers commit, so call blur to avoid multiple commits
fieldRef.current?.blur();
}
if (e.key === 'Escape') {
e.stopPropagation();
cancel();
}
};
Expand Down Expand Up @@ -298,6 +321,8 @@ const EditableField: React.FC<EditableFieldProps> = (props) => {
<span style={{ color: SharedColors.red20 }}>{requiredMessage || formErrors.value}</span>
)}
{error && <span style={{ color: SharedColors.red20 }}>{error}</span>}
{hasErrors && hasBeenEdited && <Announced message={requiredMessage || formErrors.value} role="alert" />}
{error && <Announced message={error} role="alert" />}
</Fragment>
);
};
Expand Down
29 changes: 29 additions & 0 deletions Composer/packages/client/src/hooks/useAfterRender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useCallback, useLayoutEffect, useRef } from 'react';

/**
* Run a callback function not earlier than immediate re-renders happen
* @returns onAfterRender
*/
export const useAfterRender = () => {
const timeout = useRef<NodeJS.Timeout>();
const callback = useRef<(() => void) | null>(null);

useLayoutEffect(() => {
callback.current?.();
});

return useCallback((fn: () => unknown) => {
callback.current = () => {
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
callback.current = null;
fn();
}, 0);
};

callback.current?.();
}, []);
};
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const rowDetails = {
'.ms-GroupHeader-expand': {
fontSize: 8,
},
'&:hover': {
'&:hover, &:focus-within': {
background: NeutralColors.gray30,
selectors: {
'.ms-TextField-fieldGroup': {
Expand Down
19 changes: 13 additions & 6 deletions Composer/packages/client/src/pages/knowledge-base/table-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { EditQnAModal } from '../../components/QnA/EditQnAFrom';
import { ReplaceQnAFromModal } from '../../components/QnA/ReplaceQnAFromModal';
import { getQnAFileUrlOption } from '../../utils/qnaUtil';
import TelemetryClient from '../../telemetry/TelemetryClient';
import { CellFocusZone } from '../../components/CellFocusZone';

import {
formCell,
Expand Down Expand Up @@ -566,7 +567,8 @@ const TableView: React.FC<TableViewProps> = (props) => {
const addQuestionButton = (
<ActionButton
styles={addAlternative}
onClick={() => {
onClick={(ev) => {
ev.stopPropagation();
setCreatingQuestionInKthSection(item.sectionId);
TelemetryClient.track('AlternateQnAPhraseAdded');
}}
Expand All @@ -576,7 +578,7 @@ const TableView: React.FC<TableViewProps> = (props) => {
);

return (
<div data-is-focusable css={formCell}>
<CellFocusZone>
{questions.map((question, qIndex: number) => {
const isQuestionEmpty = question.content === '';
const isOnlyQuestion = questions.length === 1 && qIndex === 0;
Expand Down Expand Up @@ -627,6 +629,9 @@ const TableView: React.FC<TableViewProps> = (props) => {
}}
onChange={() => {}}
onFocus={() => setExpandedIndex(index)}
onNewLine={() => {
setCreatingQuestionInKthSection(item.sectionId);
}}
/>
</div>
);
Expand Down Expand Up @@ -674,11 +679,12 @@ const TableView: React.FC<TableViewProps> = (props) => {
}}
onChange={() => {}}
onFocus={() => setExpandedIndex(index)}
onNewLine={() => {}}
/>
) : (
addQuestionButton
)}
</div>
</CellFocusZone>
);
},
},
Expand All @@ -698,7 +704,7 @@ const TableView: React.FC<TableViewProps> = (props) => {
item.fileId === createQnAPairSettings.groupKey && index === createQnAPairSettings.sectionIndex;

return (
<div data-is-focusable css={formCell}>
<CellFocusZone>
<EditableField
required
ariaLabel={formatMessage(`Answer is {content}`, { content: item.Answer })}
Expand Down Expand Up @@ -741,7 +747,7 @@ const TableView: React.FC<TableViewProps> = (props) => {
onChange={() => {}}
onFocus={() => setExpandedIndex(index)}
/>
</div>
</CellFocusZone>
);
},
},
Expand All @@ -755,7 +761,7 @@ const TableView: React.FC<TableViewProps> = (props) => {
data: 'string',
onRender: (item) => {
return (
<div data-is-focusable css={formCell} style={{ marginTop: 10, marginLeft: 13 }}>
<div css={formCell} style={{ marginTop: 10, marginLeft: 13 }}>
{item.usedIn.map(({ id, displayName }) => {
return (
<Link
Expand Down Expand Up @@ -928,6 +934,7 @@ const TableView: React.FC<TableViewProps> = (props) => {
checkboxVisibility={CheckboxVisibility.hidden}
columns={getTableColums()}
componentRef={detailListRef}
getKey={(item, index) => `row-${index}`}
groupProps={{
onRenderHeader: onRenderGroupHeader,
collapseAllVisibility: CollapseAllVisibility.hidden,
Expand Down
Loading