From 9a695c818c1f48d60467d1d6d151d869eaad5645 Mon Sep 17 00:00:00 2001 From: Nisha Yerunkar Date: Fri, 2 Aug 2024 14:03:02 -0700 Subject: [PATCH 1/2] [Interactive Graph Editor] Stop page scrolling on number text field focused scroll --- .changeset/purple-dots-beam.md | 5 ++ .../scrollless-number-text-field.stories.tsx | 82 +++++++++++++++++++ .../scrollless-number-text-field.test.tsx | 56 +++++++++++++ .../src/components/angle-input.tsx | 5 +- .../src/components/coordinate-pair-input.tsx | 9 +- .../locked-polygon-settings.tsx | 2 +- .../scrollless-number-text-field.tsx | 45 ++++++++++ .../src/components/start-coords-circle.tsx | 5 +- 8 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 .changeset/purple-dots-beam.md create mode 100644 packages/perseus-editor/src/components/__stories__/scrollless-number-text-field.stories.tsx create mode 100644 packages/perseus-editor/src/components/__tests__/scrollless-number-text-field.test.tsx create mode 100644 packages/perseus-editor/src/components/scrollless-number-text-field.tsx diff --git a/.changeset/purple-dots-beam.md b/.changeset/purple-dots-beam.md new file mode 100644 index 0000000000..f4c3d53be9 --- /dev/null +++ b/.changeset/purple-dots-beam.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-editor": patch +--- + +[Interactive Graph Editor] Stop page scrolling on number text field focused scroll diff --git a/packages/perseus-editor/src/components/__stories__/scrollless-number-text-field.stories.tsx b/packages/perseus-editor/src/components/__stories__/scrollless-number-text-field.stories.tsx new file mode 100644 index 0000000000..7a00a45a63 --- /dev/null +++ b/packages/perseus-editor/src/components/__stories__/scrollless-number-text-field.stories.tsx @@ -0,0 +1,82 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; +import * as React from "react"; + +import ScrolllessNumberTextField from "../scrollless-number-text-field"; + +import type {StoryObj, Meta} from "@storybook/react"; + +export default { + title: "PerseusEditor/Components/Scrollless Number Text Field", + component: ScrolllessNumberTextField, +} as Meta; + +/** + * Uncontrolled story. Interact with the control panel to see the component + * reflect the props. + */ +export const Default = (args): React.ReactElement => { + return ; +}; + +const defaultProps = { + value: "", + onChange: () => {}, +}; + +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + +/** + * Controlled story. The text field's state is managed by its parent. + * Typing in the input field should work as expected. + */ +export const Controlled: StoryComponentType = { + render: function Render() { + const [value, setValue] = React.useState(""); + + return ; + }, +}; + +Controlled.parameters = { + chromatic: { + // Disable the snapshot for this story because it's testing + // behavior, not visuals. + disable: true, + }, +}; + +/** + * In this example, we can see how the input field behaves when it is placed + * in a long page. Scrolling on the input field with a mouse wheel or trackpad + * changes the number, but does not scroll the page. + */ +export const LongPageScroll: StoryComponentType = { + render: function Render() { + const [value, setValue] = React.useState(""); + + return ( + <> + Scroll down to see the input. + + + Observe that scrolling on the input field with a mouse wheel + changes the number, but does not scroll the page. + + + + + ); + }, +}; + +LongPageScroll.parameters = { + chromatic: { + // Disable the snapshot for this story because it's testing + // behavior, not visuals. + disable: true, + }, +}; diff --git a/packages/perseus-editor/src/components/__tests__/scrollless-number-text-field.test.tsx b/packages/perseus-editor/src/components/__tests__/scrollless-number-text-field.test.tsx new file mode 100644 index 0000000000..1bce852dff --- /dev/null +++ b/packages/perseus-editor/src/components/__tests__/scrollless-number-text-field.test.tsx @@ -0,0 +1,56 @@ +import {render, screen} from "@testing-library/react"; +import {userEvent as userEventLib} from "@testing-library/user-event"; +import * as React from "react"; + +import ScrolllessNumberTextField from "../scrollless-number-text-field"; + +import type {UserEvent} from "@testing-library/user-event"; + +describe("ScrolllessNumberTextField", () => { + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + }); + + test("Should render a number input", () => { + // Arrange + const onChange = jest.fn(); + + // Act + render(); + + const input = screen.getByRole("spinbutton"); + + // Assert + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(42); + }); + + test("Should call the onChange callback when the value changes", async () => { + // Arrange + const onChange = jest.fn(); + render(); + const input = screen.getByRole("spinbutton"); + + // Act + await userEvent.type(input, "2"); + + // Assert + expect(onChange).toHaveBeenLastCalledWith("2"); + }); + + test("Should not call the onChange callback when the value is not a number", async () => { + // Arrange + const onChange = jest.fn(); + render(); + const input = screen.getByRole("spinbutton"); + + // Act + await userEvent.type(input, "a"); + + // Assert + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/perseus-editor/src/components/angle-input.tsx b/packages/perseus-editor/src/components/angle-input.tsx index 67d6374c2f..77e99df55e 100644 --- a/packages/perseus-editor/src/components/angle-input.tsx +++ b/packages/perseus-editor/src/components/angle-input.tsx @@ -1,10 +1,10 @@ -import {TextField} from "@khanacademy/wonder-blocks-form"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; import {StyleSheet} from "aphrodite"; import * as React from "react"; +import ScrolllessNumberTextField from "./scrollless-number-text-field"; import {degreeToRadian, radianToDegree} from "./util"; type Props = { @@ -37,8 +37,7 @@ const AngleInput = (props: Props) => { angle (degrees) - { {labels ? labels[0] : "x coord"} - handleCoordChange(newValue, 0)} style={[ @@ -74,8 +74,7 @@ const CoordinatePairInput = (props: Props) => { {labels ? labels[1] : "y coord"} - handleCoordChange(newValue, 1)} style={[ diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx index af73527d4a..fa9e3dedc6 100644 --- a/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx @@ -142,7 +142,7 @@ const LockedPolygonSettings = (props: Props) => { return ( {/* Give the points alphabet labels */} diff --git a/packages/perseus-editor/src/components/scrollless-number-text-field.tsx b/packages/perseus-editor/src/components/scrollless-number-text-field.tsx new file mode 100644 index 0000000000..21222ac3a6 --- /dev/null +++ b/packages/perseus-editor/src/components/scrollless-number-text-field.tsx @@ -0,0 +1,45 @@ +import {TextField} from "@khanacademy/wonder-blocks-form"; +import * as React from "react"; + +import type {PropsFor} from "@khanacademy/wonder-blocks-core"; + +/** + * This is a custom text field of type="number" for use in Perseus Editors. + * + * This makes it so that the text field's input number updates on scroll + * without scrolling the page. + * + * NOTE 1: Native HTML number inputs do not update the number value on scroll, + * they only scroll the page. For some reason, inputs in React do NOT work + * this way. By default, scrolling on a focused number input in React causes + * BOTH the input value to change AND the page to scroll. The behavior in + * this component is an improvement on the React behavior, but it's the + * opposite of the native HTML behavior. + * + * NOTE 2: Firefox seems to have a custom override for this. Even with this + * stopPropogation, Firefox matches the native HTML behavior. + */ +const ScrolllessNumberTextField = (props: PropsFor) => { + const inputRef = React.useRef(null); + + React.useEffect(() => { + const ref = inputRef.current; + + // stopPropogation makes it so that the page scroll event is not + // triggered when the input is focused and the user scrolls. + // The input value will still change on scroll. + const ignoreScroll = (e) => { + e.stopPropagation(); + }; + + ref?.addEventListener("wheel", ignoreScroll); + + return () => { + ref?.removeEventListener("wheel", ignoreScroll); + }; + }, [inputRef]); + + return ; +}; + +export default ScrolllessNumberTextField; diff --git a/packages/perseus-editor/src/components/start-coords-circle.tsx b/packages/perseus-editor/src/components/start-coords-circle.tsx index d3eadf1ec7..9671b4f62d 100644 --- a/packages/perseus-editor/src/components/start-coords-circle.tsx +++ b/packages/perseus-editor/src/components/start-coords-circle.tsx @@ -1,5 +1,4 @@ import {View} from "@khanacademy/wonder-blocks-core"; -import {TextField} from "@khanacademy/wonder-blocks-form"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; @@ -7,6 +6,7 @@ import {StyleSheet} from "aphrodite"; import * as React from "react"; import CoordinatePairInput from "./coordinate-pair-input"; +import ScrolllessNumberTextField from "./scrollless-number-text-field"; import type {Coord, PerseusGraphType} from "@khanacademy/perseus"; @@ -42,9 +42,8 @@ const StartCoordsCircle = (props: Props) => { Radius: - { onChange({ center: startCoords.center, From a1d54f4f61f3f8ec5c806c56497e2da12d0dd45c Mon Sep 17 00:00:00 2001 From: Nisha Yerunkar Date: Mon, 5 Aug 2024 12:31:51 -0700 Subject: [PATCH 2/2] Link explanation in comment --- .../src/components/scrollless-number-text-field.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/perseus-editor/src/components/scrollless-number-text-field.tsx b/packages/perseus-editor/src/components/scrollless-number-text-field.tsx index 21222ac3a6..53553c0e64 100644 --- a/packages/perseus-editor/src/components/scrollless-number-text-field.tsx +++ b/packages/perseus-editor/src/components/scrollless-number-text-field.tsx @@ -10,11 +10,11 @@ import type {PropsFor} from "@khanacademy/wonder-blocks-core"; * without scrolling the page. * * NOTE 1: Native HTML number inputs do not update the number value on scroll, - * they only scroll the page. For some reason, inputs in React do NOT work - * this way. By default, scrolling on a focused number input in React causes - * BOTH the input value to change AND the page to scroll. The behavior in - * this component is an improvement on the React behavior, but it's the - * opposite of the native HTML behavior. + * they only scroll the page. Inputs in React do NOT work this way (explanation + * here: https://stackoverflow.com/a/68266494). By default, scrolling on a + * focused number input in React causes BOTH the input value to change AND + * the page to scroll. The behavior in this component is an improvement on + * the React behavior, but it's the opposite of the native HTML behavior. * * NOTE 2: Firefox seems to have a custom override for this. Even with this * stopPropogation, Firefox matches the native HTML behavior.