diff --git a/.changeset/rich-flowers-prove.md b/.changeset/rich-flowers-prove.md new file mode 100644 index 0000000000..1c1087443b --- /dev/null +++ b/.changeset/rich-flowers-prove.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/math-input": minor +"@khanacademy/perseus": minor +--- + +Modernization and Migration of InputWithExamples to NumericInput folder diff --git a/packages/perseus/src/components/input-with-examples.tsx b/packages/perseus/src/components/input-with-examples.tsx deleted file mode 100644 index 3010ddcfa6..0000000000 --- a/packages/perseus/src/components/input-with-examples.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import * as PerseusLinter from "@khanacademy/perseus-linter"; -import * as React from "react"; -import _ from "underscore"; - -import {ClassNames as ApiClassNames} from "../perseus-api"; -import Renderer from "../renderer"; -import Util from "../util"; - -import {PerseusI18nContext} from "./i18n-context"; -import TextInput from "./text-input"; -import Tooltip, {HorizontalDirection, VerticalDirection} from "./tooltip"; - -import type {LinterContextProps} from "@khanacademy/perseus-linter"; -import type {StyleType} from "@khanacademy/wonder-blocks-core"; - -const {captureScratchpadTouchStart} = Util; - -type Props = { - value: string; - onChange: any; - className: string; - examples: ReadonlyArray; - shouldShowExamples: boolean; - convertDotToTimes?: boolean; - buttonSet?: string; - buttonsVisible?: "always" | "never" | "focused"; - labelText?: string; - onFocus: () => void; - onBlur: () => void; - disabled: boolean; - style?: StyleType; - id: string; - linterContext: LinterContextProps; -}; - -type DefaultProps = { - shouldShowExamples: Props["shouldShowExamples"]; - onFocus: Props["onFocus"]; - onBlur: Props["onBlur"]; - disabled: Props["disabled"]; - linterContext: Props["linterContext"]; - className: Props["className"]; -}; - -type State = { - focused: boolean; - showExamples: boolean; -}; - -class InputWithExamples extends React.Component { - static contextType = PerseusI18nContext; - declare context: React.ContextType; - - inputRef: React.RefObject; - - static defaultProps: DefaultProps = { - shouldShowExamples: true, - onFocus: function () {}, - onBlur: function () {}, - disabled: false, - linterContext: PerseusLinter.linterContextDefault, - className: "", - }; - - state: State = { - focused: false, - showExamples: false, - }; - - constructor(props: Props) { - super(props); - this.inputRef = React.createRef(); - } - - _getUniqueId: () => string = () => { - return `input-with-examples-${btoa(this.props.id).replace(/=/g, "")}`; - }; - - _getInputClassName: () => string = () => { - // Otherwise, we need to add these INPUT and FOCUSED tags here. - let className = ApiClassNames.INPUT + " " + ApiClassNames.INTERACTIVE; - if (this.state.focused) { - className += " " + ApiClassNames.FOCUSED; - } - if (this.props.className) { - className += " " + this.props.className; - } - return className; - }; - - _renderInput: () => any = () => { - const id = this._getUniqueId(); - const ariaId = `aria-for-${id}`; - // Generate text from a known set of format options that will read well in a screen reader - const examplesAria = - this.props.examples.length === 0 - ? "" - : `${this.props.examples[0]} - ${this.props.examples.slice(1).join(", or\n")}` - // @ts-expect-error TS2550: Property replaceAll does not exist on type string. - .replaceAll("*", "") - .replaceAll("$", "") - .replaceAll("\\ \\text{pi}", " pi") - .replaceAll("\\ ", " and "); - const inputProps = { - id: id, - "aria-describedby": ariaId, - ref: this.inputRef, - className: this._getInputClassName(), - labelText: this.props.labelText, - value: this.props.value, - onFocus: this._handleFocus, - onBlur: this._handleBlur, - disabled: this.props.disabled, - style: this.props.style, - onChange: this.props.onChange, - onTouchStart: captureScratchpadTouchStart, - autoCapitalize: "off", - autoComplete: "off", - autoCorrect: "off", - spellCheck: "false", - }; - return ( - <> - - - {examplesAria} - - - ); - }; - - _handleFocus: () => void = () => { - this.props.onFocus(); - this.setState({ - focused: true, - showExamples: true, - }); - }; - - show: () => void = () => { - this.setState({showExamples: true}); - }; - - hide: () => void = () => { - this.setState({showExamples: false}); - }; - - _handleBlur: () => void = () => { - this.props.onBlur(); - this.setState({ - focused: false, - showExamples: false, - }); - }; - - focus: () => void = () => { - this.inputRef.current?.focus(); - }; - - blur: () => void = () => { - this.inputRef.current?.blur(); - }; - - handleChange: (arg1: any) => void = (e) => { - this.props.onChange(e.target.value); - }; - render(): React.ReactNode { - const input = this._renderInput(); - - const examplesContent = - this.props.examples.length <= 2 - ? this.props.examples.join(" ") // A single item (with or without leading text) is not a "list" - : this.props.examples // 2 or more items should display as a list - .map((example, index) => { - // If the first example is bold, then it is most likely a heading/leading text. - // So, it shouldn't be part of the list. - return index === 0 && example.startsWith("**") - ? `${example}\n` - : `- ${example}`; - }) - .join("\n"); - - const showExamples = - this.props.shouldShowExamples && this.state.showExamples; - - return ( - - {input} -
- -
-
- ); - } -} - -export default InputWithExamples; diff --git a/packages/perseus/src/widgets/input-number/input-number.tsx b/packages/perseus/src/widgets/input-number/input-number.tsx index 24f2af568d..2b44fd1feb 100644 --- a/packages/perseus/src/widgets/input-number/input-number.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.tsx @@ -9,10 +9,10 @@ import * as React from "react"; import _ from "underscore"; import {PerseusI18nContext} from "../../components/i18n-context"; -import InputWithExamples from "../../components/input-with-examples"; import SimpleKeypadInput from "../../components/simple-keypad-input"; import {ApiOptions} from "../../perseus-api"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/input-number/input-number-ai-utils"; +import InputWithExamples from "../numeric-input/input-with-examples"; import type {PerseusStrings} from "../../strings"; import type {Path, Widget, WidgetExports, WidgetProps} from "../../types"; diff --git a/packages/perseus/src/components/__stories__/input-with-examples.stories.tsx b/packages/perseus/src/widgets/numeric-input/input-with-examples.stories.tsx similarity index 93% rename from packages/perseus/src/components/__stories__/input-with-examples.stories.tsx rename to packages/perseus/src/widgets/numeric-input/input-with-examples.stories.tsx index 95b31db55d..4263d4a7a9 100644 --- a/packages/perseus/src/components/__stories__/input-with-examples.stories.tsx +++ b/packages/perseus/src/widgets/numeric-input/input-with-examples.stories.tsx @@ -1,6 +1,6 @@ import {action} from "@storybook/addon-actions"; -import InputWithExamples from "../input-with-examples"; +import InputWithExamples from "./input-with-examples"; import type {Meta, StoryObj} from "@storybook/react"; diff --git a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx new file mode 100644 index 0000000000..4ceb382ca3 --- /dev/null +++ b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx @@ -0,0 +1,193 @@ +import * as PerseusLinter from "@khanacademy/perseus-linter"; +import * as React from "react"; +import {forwardRef, useImperativeHandle} from "react"; +import _ from "underscore"; + +import {PerseusI18nContext} from "../../components/i18n-context"; +import TextInput from "../../components/text-input"; +import Tooltip, { + HorizontalDirection, + VerticalDirection, +} from "../../components/tooltip"; +import {ClassNames as ApiClassNames} from "../../perseus-api"; +import Renderer from "../../renderer"; +import Util from "../../util"; + +import type {LinterContextProps} from "@khanacademy/perseus-linter"; +import type {StyleType} from "@khanacademy/wonder-blocks-core"; + +const {captureScratchpadTouchStart} = Util; + +type Props = { + value: string; + onChange: any; + className: string; + examples: ReadonlyArray; + shouldShowExamples: boolean; + convertDotToTimes?: boolean; + buttonSet?: string; + buttonsVisible?: "always" | "never" | "focused"; + labelText?: string; + onFocus: () => void; + onBlur: () => void; + disabled: boolean; + style?: StyleType; + id: string; + linterContext: LinterContextProps; +}; + +// [LEMS-2411](Jan 2025) Third: This component has been moved to the NumericInput +// folder as we are actively working towards removing the InputNumber widget. +// This comment can be removed as part of LEMS-2411. + +/** + * The InputWithExamples component is a child component of the NumericInput + * and InputNumber components. It is responsible for rendering the UI elements + * for the desktop versions of these widgets, and displays a tooltip with + * examples of how to input the selected answer forms. + */ +const InputWithExamples = forwardRef< + React.RefObject, + Props +>((props, ref) => { + // Desctructure the props to set default values + const { + shouldShowExamples = true, + onFocus = () => {}, + onBlur = () => {}, + disabled = false, + linterContext = PerseusLinter.linterContextDefault, + className = "", + } = props; + + const context = React.useContext(PerseusI18nContext); + const inputRef = React.useRef(null); + const [inputFocused, setInputFocused] = React.useState(false); + + useImperativeHandle(ref, () => ({ + current: inputRef.current, + focus: () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, + blur: () => { + if (inputRef.current) { + inputRef.current.blur(); + } + }, + })); + + const _getUniqueId = () => { + return `input-with-examples-${btoa(props.id).replace(/=/g, "")}`; + }; + + const _getInputClassName = () => { + let inputClassName = + ApiClassNames.INPUT + " " + ApiClassNames.INTERACTIVE; + if (inputFocused) { + inputClassName += " " + ApiClassNames.FOCUSED; + } + if (className) { + inputClassName += " " + className; + } + return inputClassName; + }; + + const _renderInput = () => { + const id = _getUniqueId(); + const ariaId = `aria-for-${id}`; + + // Generate the provided examples in simple language for screen readers. + // If all examples are provided, do not provide them to the screen reader. + const examplesAria = + props.examples.length === 0 + ? "" + : `${props.examples[0]} + ${props.examples.slice(1).join(", or\n")}` + // @ts-expect-error TS2550: Property replaceAll does not exist on type string. + .replaceAll("*", "") + .replaceAll("$", "") + .replaceAll("\\ \\text{pi}", " pi") + .replaceAll("\\ ", " and "); + + const inputProps = { + id: id, + "aria-describedby": ariaId, + ref: inputRef, + className: _getInputClassName(), + labelText: props.labelText, + value: props.value, + onFocus: _handleFocus, + onBlur: _handleBlur, + disabled: disabled, + style: props.style, + onChange: props.onChange, + onTouchStart: captureScratchpadTouchStart, + autoCapitalize: "off", + autoComplete: "off", + autoCorrect: "off", + spellCheck: "false", + }; + return ( + <> + + + {examplesAria} + + + ); + }; + + const _handleFocus = () => { + onFocus(); + setInputFocused(true); + }; + + const _handleBlur = () => { + onBlur(); + setInputFocused(false); + }; + + // Display the examples as a string when there are less than or equal to 2 examples. + // Otherwise, display the examples as a list. + const examplesContent = + props.examples.length <= 2 + ? props.examples.join(" ") + : props.examples + .map((example, index) => { + return index === 0 && example.startsWith("**") + ? `${example}\n` + : `- ${example}`; + }) + .join("\n"); + + // Display the examples when they are enabled (shouldShowExamples) and the input is focused. + const showExamplesTooltip = shouldShowExamples && inputFocused; + + return ( + + {_renderInput()} +
+ +
+
+ ); +}); + +export default InputWithExamples; diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx index 44b8fede57..ea89da7a0e 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx @@ -8,7 +8,7 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/numeric-inp import {NumericInputComponent} from "./numeric-input"; import {unionAnswerForms} from "./utils"; -import type InputWithExamples from "../../components/input-with-examples"; +import type InputWithExamples from "./input-with-examples"; import type SimpleKeypadInput from "../../components/simple-keypad-input"; import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; import type { @@ -74,7 +74,7 @@ export class NumericInput extends React.Component implements Widget { - inputRef: RefObject; + inputRef: RefObject; static defaultProps: DefaultProps = { currentValue: "", @@ -100,7 +100,7 @@ export class NumericInput // Create a ref that we can pass down to the input component so that we // can call focus on it when necessary. this.inputRef = React.createRef< - SimpleKeypadInput | InputWithExamples + SimpleKeypadInput | typeof InputWithExamples >(); } diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx index 5fd5c04099..81db1cbe0b 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx @@ -9,13 +9,13 @@ import { } from "react"; import {PerseusI18nContext} from "../../components/i18n-context"; -import InputWithExamples from "../../components/input-with-examples"; import SimpleKeypadInput from "../../components/simple-keypad-input"; +import InputWithExamples from "./input-with-examples"; import {type NumericInputProps} from "./numeric-input.class"; import {generateExamples, shouldShowExamples} from "./utils"; -type InputRefType = SimpleKeypadInput | InputWithExamples | null; +type InputRefType = SimpleKeypadInput | typeof InputWithExamples | null; /** * The NumericInputComponent is a child component of the NumericInput class @@ -30,15 +30,16 @@ export const NumericInputComponent = forwardRef( // Pass the focus and blur methods to the Numeric Input Class component useImperativeHandle(ref, () => ({ + current: inputRef.current, focus: () => { if (inputRef.current) { - inputRef.current.focus(); + inputRef.current?.focus(); setIsFocused(true); } }, blur: () => { if (inputRef.current) { - inputRef.current.blur(); + inputRef.current?.blur(); setIsFocused(false); } }, @@ -105,7 +106,7 @@ export const NumericInputComponent = forwardRef( // (desktop-only) Otherwise, use the InputWithExamples component return ( } + ref={inputRef as React.RefObject} value={props.currentValue} onChange={handleChange} labelText={labelText}