Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Interactive Graph Editor] Stop page scrolling on number text field focused scroll #1481

Merged
merged 2 commits into from
Aug 5, 2024
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
5 changes: 5 additions & 0 deletions .changeset/purple-dots-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus-editor": patch
---

[Interactive Graph Editor] Stop page scrolling on number text field focused scroll
Original file line number Diff line number Diff line change
@@ -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<typeof ScrolllessNumberTextField>;

/**
* Uncontrolled story. Interact with the control panel to see the component
* reflect the props.
*/
export const Default = (args): React.ReactElement => {
return <ScrolllessNumberTextField {...args} />;
};

const defaultProps = {
value: "",
onChange: () => {},
};

type StoryComponentType = StoryObj<typeof ScrolllessNumberTextField>;

// 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 <ScrolllessNumberTextField value={value} onChange={setValue} />;
},
};

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 (
<>
<LabelLarge>Scroll down to see the input.</LabelLarge>
<View style={{height: "100vh"}} />
<LabelLarge>
Observe that scrolling on the input field with a mouse wheel
changes the number, but does not scroll the page.
</LabelLarge>
<ScrolllessNumberTextField value={value} onChange={setValue} />
<View style={{height: "100vh"}} />
</>
);
},
};

LongPageScroll.parameters = {
chromatic: {
// Disable the snapshot for this story because it's testing
// behavior, not visuals.
disable: true,
},
};
Original file line number Diff line number Diff line change
@@ -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(<ScrolllessNumberTextField value="42" onChange={onChange} />);

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(<ScrolllessNumberTextField value="" onChange={onChange} />);
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(<ScrolllessNumberTextField value="42" onChange={onChange} />);
const input = screen.getByRole("spinbutton");

// Act
await userEvent.type(input, "a");

// Assert
expect(onChange).not.toHaveBeenCalled();
});
});
5 changes: 2 additions & 3 deletions packages/perseus-editor/src/components/angle-input.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -37,8 +37,7 @@ const AngleInput = (props: Props) => {
<LabelMedium tag="label" style={styles.row}>
angle (degrees)
<Strut size={spacing.xxSmall_6} />
<TextField
type="number"
<ScrolllessNumberTextField
value={angleInput}
onChange={handleAngleChange}
style={styles.textField}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {View} from "@khanacademy/wonder-blocks-core";
import {TextField} from "@khanacademy/wonder-blocks-form";
import {Strut} from "@khanacademy/wonder-blocks-layout";
import {color as wbColor, 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 type {Coord} from "@khanacademy/perseus";
import type {StyleType} from "@khanacademy/wonder-blocks-core";

Expand Down Expand Up @@ -58,8 +59,7 @@ const CoordinatePairInput = (props: Props) => {
{labels ? labels[0] : "x coord"}

<Strut size={spacing.xxSmall_6} />
<TextField
type="number"
<ScrolllessNumberTextField
value={coordState[0]}
onChange={(newValue) => handleCoordChange(newValue, 0)}
style={[
Expand All @@ -74,8 +74,7 @@ const CoordinatePairInput = (props: Props) => {
{labels ? labels[1] : "y coord"}

<Strut size={spacing.xxSmall_6} />
<TextField
type="number"
<ScrolllessNumberTextField
value={coordState[1]}
onChange={(newValue) => handleCoordChange(newValue, 1)}
style={[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const LockedPolygonSettings = (props: Props) => {

return (
<View
key={`locked-polygon-point-${point[0]}-${point[1]}-index-${index}`}
key={`locked-polygon-point-index-${index}`}
style={[styles.row, styles.spaceUnder]}
>
{/* Give the points alphabet labels */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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. 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.
*/
const ScrolllessNumberTextField = (props: PropsFor<typeof TextField>) => {
const inputRef = React.useRef<HTMLInputElement>(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();

Check warning on line 32 in packages/perseus-editor/src/components/scrollless-number-text-field.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus-editor/src/components/scrollless-number-text-field.tsx#L32

Added line #L32 was not covered by tests
};

ref?.addEventListener("wheel", ignoreScroll);

return () => {
ref?.removeEventListener("wheel", ignoreScroll);
};
}, [inputRef]);

return <TextField type="number" {...props} ref={inputRef} />;
};

export default ScrolllessNumberTextField;
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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";
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";

Expand Down Expand Up @@ -42,9 +42,8 @@ const StartCoordsCircle = (props: Props) => {
<View style={styles.row}>
<LabelLarge>Radius:</LabelLarge>
<Strut size={spacing.small_12} />
<TextField
<ScrolllessNumberTextField
value={startCoords.radius.toString()}
type="number"
onChange={(value) => {
onChange({
center: startCoords.center,
Expand Down