diff --git a/.changeset/happy-waves-nail.md b/.changeset/happy-waves-nail.md new file mode 100644 index 00000000000..f71ea750ae8 --- /dev/null +++ b/.changeset/happy-waves-nail.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[ColorSync][numeric input] Add more regression stories for the Numeric Input widget diff --git a/packages/perseus/src/styles/widgets/numeric.css b/packages/perseus/src/styles/widgets/numeric.css index cdc0d5a4829..d7cf1ec14b4 100644 --- a/packages/perseus/src/styles/widgets/numeric.css +++ b/packages/perseus/src/styles/widgets/numeric.css @@ -6,7 +6,7 @@ } .input-with-examples-tooltip ul { list-style-type: disc; - margin-left: 30px; + margin-inline-start: 30px; } .input-with-examples-tooltip strong { font-weight: 700; diff --git a/packages/perseus/src/widgets/__docs__/nested-widgets-initial-state-regression.stories.tsx b/packages/perseus/src/widgets/__docs__/nested-widgets-initial-state-regression.stories.tsx index fbe92e9f370..8e933a9fae1 100644 --- a/packages/perseus/src/widgets/__docs__/nested-widgets-initial-state-regression.stories.tsx +++ b/packages/perseus/src/widgets/__docs__/nested-widgets-initial-state-regression.stories.tsx @@ -1,6 +1,7 @@ import { type GradedGroupWidget, type PerseusExplanationWidgetOptions, + type PerseusRenderer, } from "@khanacademy/perseus-core"; import {themeModes} from "../../../../../.storybook/modes"; @@ -11,6 +12,8 @@ import { explanationWithDefinitionOptions, gradedGroupWithRadioAndDefinition, gradedGroupWithRadioAndExplanation, + numericInputInGradedGroup, + numericInputInTable, gradedGroupWithInteractiveGraphAndRadio, } from "./nested-widgets.testdata"; @@ -64,3 +67,19 @@ export const ExplanationWithDefinition: ExplanationStory = { decorators: [articleRendererDecorator, explanationRendererDecorator], args: explanationWithDefinitionOptions, }; + +type RendererStory = StoryObj; + +export const NumericInputInTable: RendererStory = { + decorators: [articleRendererDecorator], + parameters: { + question: numericInputInTable, + }, +}; + +export const NumericInputInGradedGroup: RendererStory = { + decorators: [articleRendererDecorator], + parameters: { + question: numericInputInGradedGroup, + }, +}; diff --git a/packages/perseus/src/widgets/__docs__/nested-widgets-interactions-regression.stories.tsx b/packages/perseus/src/widgets/__docs__/nested-widgets-interactions-regression.stories.tsx index 252d47fb29f..e605fce5e5e 100644 --- a/packages/perseus/src/widgets/__docs__/nested-widgets-interactions-regression.stories.tsx +++ b/packages/perseus/src/widgets/__docs__/nested-widgets-interactions-regression.stories.tsx @@ -13,6 +13,7 @@ import { gradedGroupWithRadioAndDefinition, gradedGroupWithRadioAndExplanation, imageInContent, + numericInputInExplanation, videoInContent, } from "./nested-widgets.testdata"; @@ -144,3 +145,16 @@ export const ExplanationWithDefinition: ExplanationStory = { await userEvent.click(definitionTrigger); }, }; + +export const NumericInputInExplanation: StoryObj = { + decorators: [articleRendererDecorator], + parameters: { + question: numericInputInExplanation, + }, + play: async ({canvas, userEvent}) => { + const explanationTrigger = canvas.getByRole("button", { + name: "Show practice problem", + }); + await userEvent.click(explanationTrigger); + }, +}; diff --git a/packages/perseus/src/widgets/__docs__/nested-widgets.testdata.ts b/packages/perseus/src/widgets/__docs__/nested-widgets.testdata.ts index 2bb2fd50144..1d8231702ee 100644 --- a/packages/perseus/src/widgets/__docs__/nested-widgets.testdata.ts +++ b/packages/perseus/src/widgets/__docs__/nested-widgets.testdata.ts @@ -8,6 +8,8 @@ import { generateIGPointGraph, generateImageOptions, generateImageWidget, + generateNumericInputOptions, + generateNumericInputWidget, generateInteractiveGraphOptions, generateInteractiveGraphWidget, generateRadioChoice, @@ -223,6 +225,69 @@ export const definitionInContentAndExplanation: PerseusRenderer = }, }); +export const numericInputInTable: PerseusRenderer = generateTestPerseusRenderer( + { + content: + "| Normal-size input | Small-size input |\n" + + "| --- | --- |\n" + + "| [[☃ numeric-input 1]] | [[☃ numeric-input 2]] |", + widgets: { + "numeric-input 1": generateNumericInputWidget({ + options: generateNumericInputOptions({size: "normal"}), + }), + "numeric-input 2": generateNumericInputWidget({ + options: generateNumericInputOptions({size: "small"}), + }), + }, + }, +); + +export const numericInputInGradedGroup: PerseusRenderer = + generateTestPerseusRenderer({ + content: "[[☃ graded-group 1]]", + widgets: { + "graded-group 1": generateGradedGroupWidget({ + options: generateGradedGroupOptions({ + title: "USS Enterprise registry", + content: + "What is the registry number of the original USS " + + "Enterprise?\n\nNCC-[[☃ numeric-input 1]]", + widgets: { + "numeric-input 1": generateNumericInputWidget({ + options: generateNumericInputOptions({ + size: "normal", + }), + }), + }, + }), + }), + }, + }); + +export const numericInputInExplanation: PerseusRenderer = + generateTestPerseusRenderer({ + content: "Warp factors are not linear.\n\n[[☃ explanation 1]]", + widgets: { + "explanation 1": generateExplanationWidget({ + options: generateExplanationOptions({ + showPrompt: "Show practice problem", + hidePrompt: "Hide practice problem", + explanation: + "Warp 1 equals the speed of light. Enter the warp " + + "factor that equals twice the speed of light: " + + "[[☃ numeric-input 1]]", + widgets: { + "numeric-input 1": generateNumericInputWidget({ + options: generateNumericInputOptions({ + size: "normal", + }), + }), + }, + }), + }), + }, + }); + export const explanationWithDefinitionOptions: PerseusExplanationWidgetOptions = generateExplanationOptions({ explanation: diff --git a/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-initial-state-regression.stories.tsx b/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-initial-state-regression.stories.tsx index 4d47c403936..f3198d62a45 100644 --- a/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-initial-state-regression.stories.tsx +++ b/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-initial-state-regression.stories.tsx @@ -1,4 +1,14 @@ +import { + generateDropdownOptions, + generateDropdownWidget, + generateNumericInputOptions, + generateNumericInputWidget, + generateTestPerseusRenderer, +} from "@khanacademy/perseus-core"; +import * as React from "react"; + import {themeModes} from "../../../../../../.storybook/modes"; +import QuestionRendererForStories from "../../__testutils__/question-renderer-for-stories"; import {numericInputRendererDecorator} from "./numeric-input-renderer-decorator"; @@ -69,3 +79,106 @@ export const CenterTextAlign: Story = { textAlign: "center", }, }; + +// Verifies that a very long number does not overflow or distort the input box +export const LongNumber: Story = { + decorators: [numericInputRendererDecorator], + parameters: { + initialUserInput: { + "numeric-input 1": {currentValue: "12345678901234567890"}, + }, + }, + args: { + size: "normal", + }, +}; + +export const MultipleInputsInParagraph: Story = { + decorators: [ + (Story) => ( + // Limit the width to force two inputs to stack vertically. +
+ +
+ ), + ], + render: function Render() { + return ( + + ); + }, +}; + +/** + * Verifies the vertical spacing/baseline alignment when a numeric input sits + * inline with a dropdown in the same paragraph. + */ +export const InlineWithDropdown: Story = { + render: function Render() { + return ( + + ); + }, +}; diff --git a/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-interactions-regression.stories.tsx b/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-interactions-regression.stories.tsx index 1021adc44f9..b770fc32a76 100644 --- a/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-interactions-regression.stories.tsx +++ b/packages/perseus/src/widgets/numeric-input/__docs__/numeric-input-interactions-regression.stories.tsx @@ -1,8 +1,28 @@ +import { + KeypadContext, + StatefulKeypadContextProvider, +} from "@khanacademy/keypad-context"; +import {MobileKeypad} from "@khanacademy/math-input"; +import { + generateNumericInputOptions, + generateNumericInputWidget, + generateTestPerseusItem, + generateTestPerseusRenderer, +} from "@khanacademy/perseus-core"; +import * as React from "react"; +import {expect, fireEvent} from "storybook/test"; + import {themeModes} from "../../../../../../.storybook/modes"; +import WrappedServerItemRenderer from "../../../server-item-renderer"; +import {storybookDependenciesV2} from "../../../testing/test-dependencies"; +import {rtlDecorator} from "../../__testutils__/story-decorators"; import {numericInputRendererDecorator} from "./numeric-input-renderer-decorator"; -import type {PerseusNumericInputWidgetOptions} from "@khanacademy/perseus-core"; +import type { + PerseusNumericInputWidgetOptions, + PerseusRenderer, +} from "@khanacademy/perseus-core"; import type {Meta, StoryObj} from "@storybook/react-vite"; const meta: Meta = { @@ -38,7 +58,7 @@ export const Focus: Story = { }; /** Verifies the focused state with one answer form (integer) — tooltip shows a single example */ -export const With1Tooltip: Story = { +export const WithTooltipOneAnswerForm: Story = { decorators: [numericInputRendererDecorator], parameters: { initialUserInput: {"numeric-input 1": {currentValue: "1701"}}, @@ -64,7 +84,7 @@ export const With1Tooltip: Story = { }; /** Verifies the focused state with multiple answer forms (integer + decimal) — tooltip shows a list of examples */ -export const WithMultipleTooltips: Story = { +export const WithTooltipMultipleAnswerForms: Story = { decorators: [numericInputRendererDecorator], parameters: { initialUserInput: {"numeric-input 1": {currentValue: "1701"}}, @@ -88,3 +108,185 @@ export const WithMultipleTooltips: Story = { input.focus(); }, }; + +export const WithTooltipMultipleAnswerFormsRTL: Story = { + decorators: [numericInputRendererDecorator, rtlDecorator], + parameters: { + initialUserInput: {"numeric-input 1": {currentValue: "1701"}}, + }, + args: { + size: "normal", + answers: [ + { + value: 5, + status: "correct", + message: "", + answerForms: ["integer", "decimal"], + simplify: "required" as const, + strict: false, + maxError: 0, + }, + ], + }, + play: async ({canvas}) => { + const input = canvas.getByRole("textbox"); + input.focus(); + }, +}; + +// Six of the seven supported answer forms. We deliberately stop at six: +// supplying all seven flips the widget into "all forms accepted" mode, which +// HIDES the examples tooltip entirely (see `shouldShowExamples`). Six is the +// most forms we can show while still rendering the full bulleted example list. +const allAnswerForms: PerseusNumericInputWidgetOptions["answers"] = [ + { + value: 5, + status: "correct", + message: "", + answerForms: [ + "integer", + "decimal", + "proper", + "improper", + "mixed", + "pi", + ], + simplify: "optional", + strict: false, + maxError: 0, + }, +]; + +/** + * Verifies the focused tooltip when many answer forms are accepted. With more + * than two examples the tooltip switches from an inline string to a bulleted + * list, so this guards that bullet layout. + */ +export const WithTooltipAllAnswerForms: Story = { + decorators: [numericInputRendererDecorator], + parameters: { + initialUserInput: {"numeric-input 1": {currentValue: "1701"}}, + }, + args: { + size: "normal", + answers: allAnswerForms, + }, + play: async ({canvas}) => { + const input = canvas.getByRole("textbox"); + input.focus(); + }, +}; + +/** + * The same full bulleted example list as WithTooltipAllAnswerForms, but in RTL. + * Verifies the bullet points render mirrored on the reverse (right) side. + */ +export const WithTooltipAllAnswerFormsRTL: Story = { + decorators: [numericInputRendererDecorator, rtlDecorator], + parameters: { + initialUserInput: {"numeric-input 1": {currentValue: "1701"}}, + }, + args: { + size: "normal", + answers: allAnswerForms, + }, + play: async ({canvas}) => { + const input = canvas.getByRole("textbox"); + input.focus(); + }, +}; + +const mobileQuestion = generateTestPerseusRenderer({ + content: "Enter the warp factor: [[☃ numeric-input 1]]", + widgets: { + "numeric-input 1": generateNumericInputWidget({ + options: generateNumericInputOptions({size: "normal"}), + }), + }, +}); + +/** + * Renders a question with the full mobile on-screen keypad wired up. + */ +function MobileKeypadItemRenderer({question}: {question: PerseusRenderer}) { + return ( + + + {({keypadElement}) => ( + + )} + + + {({setKeypadElement}) => ( + {}} + onAnalyticsEvent={async () => {}} + /> + )} + + + ); +} + +export const MobilePhoneBasicKeypadOpen: Story = { + render: () => ( +
+ +
+ ), + play: async ({canvas}) => { + const input = await canvas.findByLabelText( + "Math input box Tap with one or two fingers to open keyboard", + ); + fireEvent.touchStart(input); + // Wait for the keypad to finish its open animation. + await expect( + canvas.findByRole("button", {name: "1"}), + ).resolves.toBeVisible(); + }, +}; + +export const MobileTabletExpandedKeypadOpen: Story = { + render: () => ( +
+ +
+ ), + play: async ({canvas}) => { + const input = await canvas.findByLabelText( + "Math input box Tap with one or two fingers to open keyboard", + ); + fireEvent.touchStart(input); + // Wait for the keypad to finish its open animation. + await expect( + canvas.findByRole("button", {name: "1"}), + ).resolves.toBeVisible(); + }, +};