Skip to content

Commit e538e20

Browse files
Schwehn42BingeCode
andauthored
feat: Improved keyboard navigation in template columns (#5500)
Co-authored-by: Benedikt Scheffbuch <github@benediktscheffbuch.de>
1 parent 8f7df02 commit e538e20

File tree

2 files changed

+51
-35
lines changed

2 files changed

+51
-35
lines changed

src/components/ColumnsConfigurator/ColumnsConfiguratorColumn/ColumnConfiguratorColumnNameDetails/ColumnConfiguratorColumnNameDetails.tsx

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import classNames from "classnames";
2+
import {useOnBlur} from "utils/hooks/useOnBlur";
23
import {useTranslation} from "react-i18next";
34
import {ReactComponent as CheckDoneIcon} from "assets/icons/check-done.svg";
45
import {ReactComponent as CloseIcon} from "assets/icons/close.svg";
56
import {TextArea} from "components/TextArea/TextArea";
67
import {MiniMenu, MiniMenuItem} from "components/MiniMenu/MiniMenu";
7-
import {Dispatch, SetStateAction, useRef, useState, FocusEvent} from "react";
8-
import "./ColumnConfiguratorColumnNameDetails.scss";
8+
import {Dispatch, SetStateAction, useRef, useState} from "react";
99
import {MAX_COLUMN_DESCRIPTION_LENGTH} from "constants/misc";
10+
import "./ColumnConfiguratorColumnNameDetails.scss";
1011

1112
export type OpenState = "closed" | "visualFeedback" | "nameFirst" | "descriptionFirst";
1213

@@ -26,52 +27,53 @@ export type ColumnConfiguratorColumnNameDetailsProps = {
2627
export const ColumnConfiguratorColumnNameDetails = (props: ColumnConfiguratorColumnNameDetailsProps) => {
2728
const {t} = useTranslation();
2829

29-
const nameWrapperRef = useRef<HTMLDivElement>(null);
30-
3130
// temporary state for name and description text as the changes have to be confirmed before applying
3231
const [name, setName] = useState(props.name);
3332
const [description, setDescription] = useState(props.description);
3433

3534
const isEditing = props.openState === "nameFirst" || props.openState === "descriptionFirst";
3635

36+
const nameInputRef = useRef<HTMLInputElement>(null);
37+
38+
const cancelChanges = () => {
39+
props.setOpenState("closed");
40+
nameInputRef.current?.blur(); // leave input (or we can keep typing inside it)
41+
};
42+
43+
const saveChanges = () => {
44+
props.updateColumnTitle(name, description);
45+
// show visual feedback for 2s before displaying menu options again
46+
nameInputRef.current?.blur(); // leave input (or we can keep typing inside it)
47+
props.setOpenState("visualFeedback");
48+
setTimeout(() => {
49+
props.setOpenState("closed");
50+
}, 2000);
51+
};
52+
53+
// if we leave the wrapper, reset and close
54+
const handleBlurNameWrapperContents = () => {
55+
props.setOpenState("closed");
56+
setName(props.name);
57+
setDescription(props.description);
58+
};
59+
60+
const nameWrapperRef = useOnBlur<HTMLDivElement>(handleBlurNameWrapperContents);
61+
3762
const descriptionConfirmMiniMenu: MiniMenuItem[] = [
3863
{
3964
className: "mini-menu-item--cancel",
4065
element: <CloseIcon />,
4166
label: t("Templates.ColumnsConfiguratorColumn.cancel"),
42-
onClick(): void {
43-
props.setOpenState("closed");
44-
(document.activeElement as HTMLElement)?.blur(); // leave input (or we can keep typing inside it)
45-
},
67+
onClick: cancelChanges,
4668
},
4769
{
4870
className: "mini-menu-item--save",
4971
element: <CheckDoneIcon />,
5072
label: t("Templates.ColumnsConfiguratorColumn.save"),
51-
onClick(): void {
52-
props.updateColumnTitle(name, description);
53-
// show visual feedback for 2s before displaying menu options again
54-
props.setOpenState("visualFeedback");
55-
setTimeout(() => {
56-
props.setOpenState("closed");
57-
}, 2000);
58-
},
73+
onClick: saveChanges,
5974
},
6075
];
6176

62-
// if we leave the wrapper close, otherwise leave open
63-
const handleBlurNameWrapperContents = (e: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
64-
const isFocusInsideTitleHeaderWrapper = nameWrapperRef.current?.contains(e.relatedTarget);
65-
66-
if (!isFocusInsideTitleHeaderWrapper) {
67-
props.setOpenState("closed");
68-
69-
// reset name and description to actual
70-
setName(props.name);
71-
setDescription(props.description);
72-
}
73-
};
74-
7577
const openDescriptionWithCurrentValue = () => {
7678
setDescription(props.description);
7779
props.setOpenState("descriptionFirst");
@@ -80,13 +82,25 @@ export const ColumnConfiguratorColumnNameDetails = (props: ColumnConfiguratorCol
8082
return (
8183
<div className={classNames(props.className, "column-configurator-column-name-details__name-wrapper")} ref={nameWrapperRef}>
8284
<input
85+
ref={nameInputRef}
8386
className={classNames("column-configurator-column-name-details__name", {"column-configurator-column-name-details__name--editing": isEditing})}
8487
value={name}
8588
placeholder={t("Templates.ColumnsConfiguratorColumn.namePlaceholder")}
8689
onInput={(e) => setName(e.currentTarget.value)}
8790
onFocus={() => props.setOpenState("nameFirst")}
88-
onBlur={handleBlurNameWrapperContents}
8991
autoComplete="off"
92+
onKeyDown={(e) => {
93+
// handle Enter key submission
94+
if (e.key === "Enter") {
95+
e.preventDefault();
96+
saveChanges();
97+
}
98+
// escape to cancel
99+
else if (e.key === "Escape") {
100+
e.preventDefault();
101+
cancelChanges();
102+
}
103+
}}
90104
/>
91105
{isEditing ? (
92106
<div className="column-configurator-column-name-details__description-wrapper">
@@ -98,8 +112,9 @@ export const ColumnConfiguratorColumnNameDetails = (props: ColumnConfiguratorCol
98112
embedded
99113
fitted
100114
autoFocus={props.openState === "descriptionFirst"}
101-
onBlur={handleBlurNameWrapperContents}
102115
maxLength={MAX_COLUMN_DESCRIPTION_LENGTH}
116+
onSubmit={saveChanges}
117+
onCancel={cancelChanges}
103118
/>
104119
<MiniMenu className="column-configurator-column-name-details__description-mini-menu" items={descriptionConfirmMiniMenu} small transparent />
105120
</div>

src/components/ColumnsConfigurator/ColumnsConfiguratorColumn/ColumnConfiguratorColumnNameDetails/__tests__/ColumnConfiguratorColumnNameDetails.test.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ describe("ColumnConfiguratorColumnNameDetails behaviour", () => {
5151
const setOpenStateSpy = jest.fn();
5252
const {container} = renderColumnConfiguratorColumnNameDetails({openState: "nameFirst", setOpenState: setOpenStateSpy});
5353

54-
const inputElement = container.querySelector<HTMLInputElement>(".column-configurator-column-name-details__name")!;
55-
56-
fireEvent.focus(inputElement);
57-
fireEvent.blur(inputElement);
54+
// note: useOnBlur does not actually use the native blur event, but scans for clicks outside the element instead.
55+
// this is why to simulate the blur, we just click somewhere on the document.
56+
// const wrapperElement = container.firstChild as HTMLDivElement
57+
// fireEvent.blur(wrapperElement);
58+
fireEvent.click(document);
5859

5960
expect(setOpenStateSpy).toHaveBeenCalledWith("closed");
6061
});

0 commit comments

Comments
 (0)