From 008c1acd89b804586f8823e9b147eff2a95ce349 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 16 Jan 2025 13:49:40 -0800 Subject: [PATCH 1/5] Moving Input with Examples and converting to functional --- .../src/components/input-with-examples.tsx | 213 ------------------ .../src/widgets/input-number/input-number.tsx | 2 +- .../input-with-examples.stories.tsx | 2 +- .../numeric-input/input-with-examples.tsx | 194 ++++++++++++++++ .../numeric-input/numeric-input.class.tsx | 6 +- .../widgets/numeric-input/numeric-input.tsx | 11 +- 6 files changed, 205 insertions(+), 223 deletions(-) delete mode 100644 packages/perseus/src/components/input-with-examples.tsx rename packages/perseus/src/{components/__stories__ => widgets/numeric-input}/input-with-examples.stories.tsx (93%) create mode 100644 packages/perseus/src/widgets/numeric-input/input-with-examples.tsx 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 bcb0af99fd..db41d069cd 100644 --- a/packages/perseus/src/widgets/input-number/input-number.tsx +++ b/packages/perseus/src/widgets/input-number/input-number.tsx @@ -11,10 +11,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..c992fa07ef --- /dev/null +++ b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx @@ -0,0 +1,194 @@ +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; +}; + +type State = { + focused: boolean; + showExamples: boolean; +}; + +// 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 [state, setState] = React.useState({ + focused: false, + showExamples: 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 (state.focused) { + inputClassName += " " + ApiClassNames.FOCUSED; + } + if (className) { + inputClassName += " " + className; + } + return inputClassName; + }; + + const _renderInput = () => { + const id = _getUniqueId(); + const ariaId = `aria-for-${id}`; + 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(); + setState({ + focused: true, + showExamples: true, + }); + }; + + const _handleBlur = () => { + onBlur(); + setState({ + focused: false, + showExamples: false, + }); + }; + + const examplesContent = + props.examples.length <= 2 + ? props.examples.join(" ") + : props.examples + .map((example, index) => { + return index === 0 && example.startsWith("**") + ? `${example}\n` + : `- ${example}`; + }) + .join("\n"); + + const showExamplesTooltip = shouldShowExamples && state.showExamples; + + 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 d5df5c6f37..8da928e658 100644 --- a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx +++ b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx @@ -13,7 +13,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 {NumericInputPromptJSON} from "../../widget-ai-utils/numeric-input/prompt-utils"; @@ -75,7 +75,7 @@ export class NumericInput extends React.Component implements Widget { - inputRef: RefObject; + inputRef: RefObject; static defaultProps: DefaultProps = { currentValue: "", @@ -101,7 +101,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} From 175275be2eb211aedcc2982055da8ade9d51b088 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 16 Jan 2025 13:55:44 -0800 Subject: [PATCH 2/5] docs(changeset): Modernization and Migration of InputWithExamples to NumericInput folder --- .changeset/rich-flowers-prove.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/rich-flowers-prove.md 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 From e61030f41220145870b23163d2513ee1a3f2c674 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 16 Jan 2025 14:52:06 -0800 Subject: [PATCH 3/5] [input-examples-to-numeric] Updating comments to use proper formats --- .../widgets/numeric-input/input-with-examples.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx index c992fa07ef..64603c4051 100644 --- a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx +++ b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx @@ -40,11 +40,16 @@ type State = { focused: boolean; showExamples: boolean; }; - -// 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. +// [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 From 474ea021d982db589ad55051db5ab7831a480ec1 Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 16 Jan 2025 15:06:12 -0800 Subject: [PATCH 4/5] [input-examples-to-numeric] Some additional comments to clean things up. --- .../src/widgets/numeric-input/input-with-examples.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx index 64603c4051..0d928c7338 100644 --- a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx +++ b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx @@ -104,6 +104,9 @@ const InputWithExamples = forwardRef< 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 ? "" @@ -114,6 +117,7 @@ const InputWithExamples = forwardRef< .replaceAll("$", "") .replaceAll("\\ \\text{pi}", " pi") .replaceAll("\\ ", " and "); + const inputProps = { id: id, "aria-describedby": ariaId, @@ -158,6 +162,8 @@ const InputWithExamples = forwardRef< }); }; + // 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(" ") @@ -169,6 +175,7 @@ const InputWithExamples = forwardRef< }) .join("\n"); + // Display the examples when they are enabled (shouldShowExamples) and the input is focused (showExamples). const showExamplesTooltip = shouldShowExamples && state.showExamples; return ( From 054c29c781bce21550b538ed21c1f6fd3b42629c Mon Sep 17 00:00:00 2001 From: Sarah Third Date: Thu, 16 Jan 2025 15:21:10 -0800 Subject: [PATCH 5/5] [input-examples-to-numeric] Cleaning up redundant states --- .../numeric-input/input-with-examples.tsx | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx index 0d928c7338..4ceb382ca3 100644 --- a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx +++ b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx @@ -36,10 +36,6 @@ type Props = { linterContext: LinterContextProps; }; -type State = { - focused: boolean; - showExamples: boolean; -}; // [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. @@ -66,10 +62,7 @@ const InputWithExamples = forwardRef< const context = React.useContext(PerseusI18nContext); const inputRef = React.useRef(null); - const [state, setState] = React.useState({ - focused: false, - showExamples: false, - }); + const [inputFocused, setInputFocused] = React.useState(false); useImperativeHandle(ref, () => ({ current: inputRef.current, @@ -92,7 +85,7 @@ const InputWithExamples = forwardRef< const _getInputClassName = () => { let inputClassName = ApiClassNames.INPUT + " " + ApiClassNames.INTERACTIVE; - if (state.focused) { + if (inputFocused) { inputClassName += " " + ApiClassNames.FOCUSED; } if (className) { @@ -148,18 +141,12 @@ const InputWithExamples = forwardRef< const _handleFocus = () => { onFocus(); - setState({ - focused: true, - showExamples: true, - }); + setInputFocused(true); }; const _handleBlur = () => { onBlur(); - setState({ - focused: false, - showExamples: false, - }); + setInputFocused(false); }; // Display the examples as a string when there are less than or equal to 2 examples. @@ -175,8 +162,8 @@ const InputWithExamples = forwardRef< }) .join("\n"); - // Display the examples when they are enabled (shouldShowExamples) and the input is focused (showExamples). - const showExamplesTooltip = shouldShowExamples && state.showExamples; + // Display the examples when they are enabled (shouldShowExamples) and the input is focused. + const showExamplesTooltip = shouldShowExamples && inputFocused; return (