diff --git a/.changeset/seven-trees-design.md b/.changeset/seven-trees-design.md new file mode 100644 index 0000000000..e031280e08 --- /dev/null +++ b/.changeset/seven-trees-design.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/math-input": minor +--- + +Added new Mobile Fraction Keypad View to the V2 Keypad diff --git a/packages/math-input/src/components/keypad/__tests__/keypad.test.tsx b/packages/math-input/src/components/keypad/__tests__/keypad.test.tsx index ec901d9ded..5d54af85bc 100644 --- a/packages/math-input/src/components/keypad/__tests__/keypad.test.tsx +++ b/packages/math-input/src/components/keypad/__tests__/keypad.test.tsx @@ -74,4 +74,19 @@ describe("keypad", () => { }), ).not.toBeInTheDocument(); }); + + it(`hides the tabs if providing the Fraction Keypad`, () => { + // Arrange + // Act + render( + {}} + fractionsOnly={true} + sendEvent={async () => {}} + />, + ); + + // Assert + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + }); }); diff --git a/packages/math-input/src/components/keypad/keypad-pages/fractions-page.tsx b/packages/math-input/src/components/keypad/keypad-pages/fractions-page.tsx new file mode 100644 index 0000000000..eec3afd122 --- /dev/null +++ b/packages/math-input/src/components/keypad/keypad-pages/fractions-page.tsx @@ -0,0 +1,125 @@ +import * as React from "react"; + +import Keys from "../../../data/key-configs"; +import {KeypadButton} from "../keypad-button"; +import {getCursorContextConfig} from "../utils"; + +import type {ClickKeyCallback} from "../../../types"; +import type {CursorContext} from "../../input/cursor-contexts"; + +type Props = { + onClickKey: ClickKeyCallback; + cursorContext?: typeof CursorContext[keyof typeof CursorContext]; +}; + +export default function FractionsPage(props: Props) { + const {onClickKey, cursorContext} = props; + const cursorKeyConfig = getCursorContextConfig(cursorContext); + // These keys are arranged sequentially so that tabbing follows numerical order. This + // allows us to visually mimic a keypad without affecting a11y. The visual order of the + // keys in the keypad is determined by their coordinates, not their order in the DOM. + return ( + <> + {/* Row 4 */} + + + + + {/* Row 3 */} + + + + + {/* Row 2 */} + + + + + {/* Row 1 */} + + + + {/* Side Column */} + + + + {cursorKeyConfig && ( + + )} + + + ); +} diff --git a/packages/math-input/src/components/keypad/keypad.stories.tsx b/packages/math-input/src/components/keypad/keypad.stories.tsx index fb57909c4f..c33588e5db 100644 --- a/packages/math-input/src/components/keypad/keypad.stories.tsx +++ b/packages/math-input/src/components/keypad/keypad.stories.tsx @@ -10,6 +10,7 @@ import type {ComponentStory} from "@storybook/react"; const opsPage = "Operators Page"; const numsPage = "Numbers Page"; const geoPage = "Geometry Page"; +const fracPage = "Fractions Page"; export default { title: "Full Keypad", @@ -25,6 +26,7 @@ export default { basicRelations: false, divisionKey: false, logarithms: false, + fractionsOnly: false, multiplicationDot: false, preAlgebra: false, trigonometry: false, @@ -55,6 +57,12 @@ export default { category: opsPage, }, }, + fractionsOnly: { + control: "boolean", + table: { + category: fracPage, + }, + }, multiplicationDot: { control: "boolean", table: { @@ -93,6 +101,11 @@ Trigonometry.args = { trigonometry: true, }; +export const FractionsOnly = Template.bind({}); +FractionsOnly.args = { + fractionsOnly: true, +}; + export const Everything = Template.bind({}); Everything.args = { advancedRelations: true, diff --git a/packages/math-input/src/components/keypad/keypad.tsx b/packages/math-input/src/components/keypad/keypad.tsx index 0115ff106d..4acd4eb047 100644 --- a/packages/math-input/src/components/keypad/keypad.tsx +++ b/packages/math-input/src/components/keypad/keypad.tsx @@ -7,6 +7,7 @@ import {useEffect} from "react"; import Tabbar from "../tabbar"; import ExtrasPage from "./keypad-pages/extras-page"; +import FractionsPage from "./keypad-pages/fractions-page"; import GeometryPage from "./keypad-pages/geometry-page"; import NumbersPage from "./keypad-pages/numbers-page"; import OperatorsPage from "./keypad-pages/operators-page"; @@ -31,6 +32,7 @@ export type Props = { logarithms?: boolean; basicRelations?: boolean; advancedRelations?: boolean; + fractionsOnly?: boolean; onClickKey: ClickKeyCallback; sendEvent?: SendEventFn; @@ -40,9 +42,13 @@ const defaultProps = { extraKeys: [], }; -function allPages(props: Props): ReadonlyArray { - const pages: Array = ["Numbers"]; +function getAvailableTabs(props: Props): ReadonlyArray { + // We don't want to show any available tabs on the fractions keypad + if (props.fractionsOnly) { + return []; + } + const tabs: Array = ["Numbers"]; if ( // OperatorsButtonSets props.preAlgebra || @@ -50,28 +56,32 @@ function allPages(props: Props): ReadonlyArray { props.basicRelations || props.advancedRelations ) { - pages.push("Operators"); + tabs.push("Operators"); } if (props.trigonometry) { - pages.push("Geometry"); + tabs.push("Geometry"); } if (props.extraKeys?.length) { - pages.push("Extras"); + tabs.push("Extras"); } - return pages; + return tabs; } // The main (v2) Keypad. Use this component to present an accessible, onscreen // keypad to learners for entering math expressions. export default function Keypad(props: Props) { + // If we're using the Fractions keyapd, we want to default select that page + // Otherwise, we want to default to the Numbers page + const defaultSelectedPage = props.fractionsOnly ? "Fractions" : "Numbers"; const [selectedPage, setSelectedPage] = - React.useState("Numbers"); + React.useState(defaultSelectedPage); const [isMounted, setIsMounted] = React.useState(false); - const availablePages = allPages(props); + // We don't want any tabs available on mobile fractions keypad + const availableTabs = getAvailableTabs(props); const { onClickKey, @@ -84,9 +94,20 @@ export default function Keypad(props: Props) { basicRelations, advancedRelations, showDismiss, + fractionsOnly, sendEvent, } = props; + // Use a different grid for our fraction keypad + const gridStyle = fractionsOnly + ? styles.fractionsGrid + : styles.expressionGrid; + + // This useeffect is only used to ensure that we can test the keypad in storybook + useEffect(() => { + setSelectedPage(defaultSelectedPage); + }, [fractionsOnly, defaultSelectedPage]); + useEffect(() => { if (!isMounted) { sendEvent?.({ @@ -109,7 +130,7 @@ export default function Keypad(props: Props) { return ( { setSelectedPage(tabbarItem); @@ -121,11 +142,17 @@ export default function Keypad(props: Props) { /> + {selectedPage === "Fractions" && ( + + )} {selectedPage === "Numbers" && ( )} @@ -144,13 +171,15 @@ export default function Keypad(props: Props) { {selectedPage === "Geometry" && ( )} - + {!fractionsOnly && ( + + )} ); @@ -162,10 +191,15 @@ const styles = StyleSheet.create({ tabbar: { background: Color.white, }, - grid: { + keypadGrid: { display: "grid", - gridTemplateColumns: "repeat(6, 1fr)", gridTemplateRows: "repeat(4, 1fr)", backgroundColor: "#DBDCDD", }, + expressionGrid: { + gridTemplateColumns: "repeat(6, 1fr)", + }, + fractionsGrid: { + gridTemplateColumns: "repeat(5, 1fr)", + }, }); diff --git a/packages/math-input/src/components/keypad/mobile-keypad.tsx b/packages/math-input/src/components/keypad/mobile-keypad.tsx index 350fe1309f..9456bf8d02 100644 --- a/packages/math-input/src/components/keypad/mobile-keypad.tsx +++ b/packages/math-input/src/components/keypad/mobile-keypad.tsx @@ -142,6 +142,7 @@ class MobileKeypad extends React.Component implements KeypadAPI { extraKeys={keypadConfig?.extraKeys} onClickKey={(key) => this._handleClickKey(key)} cursorContext={cursor?.context} + fractionsOnly={!isExpression} multiplicationDot={isExpression} divisionKey={isExpression} trigonometry={isExpression} diff --git a/packages/math-input/src/components/keypad/shared-keys.tsx b/packages/math-input/src/components/keypad/shared-keys.tsx index ae03f519ac..581280b25a 100644 --- a/packages/math-input/src/components/keypad/shared-keys.tsx +++ b/packages/math-input/src/components/keypad/shared-keys.tsx @@ -1,11 +1,12 @@ import * as React from "react"; import Keys from "../../data/key-configs"; -import {CursorContext} from "../input/cursor-contexts"; import {KeypadButton} from "./keypad-button"; +import {getCursorContextConfig} from "./utils"; import type {ClickKeyCallback} from "../../types"; +import type {CursorContext} from "../input/cursor-contexts"; import type {TabbarItemType} from "../tabbar"; type Props = { @@ -16,31 +17,6 @@ type Props = { divisionKey?: boolean; }; -function getCursorContextConfig( - cursorContext?: typeof CursorContext[keyof typeof CursorContext], -) { - if (!cursorContext) { - return null; - } - - switch (cursorContext) { - case CursorContext.NONE: - return null; - case CursorContext.IN_PARENS: - return Keys.JUMP_OUT_PARENTHESES; - case CursorContext.IN_SUPER_SCRIPT: - return Keys.JUMP_OUT_EXPONENT; - case CursorContext.IN_SUB_SCRIPT: - return Keys.JUMP_OUT_BASE; - case CursorContext.IN_NUMERATOR: - return Keys.JUMP_OUT_NUMERATOR; - case CursorContext.IN_DENOMINATOR: - return Keys.JUMP_OUT_DENOMINATOR; - case CursorContext.BEFORE_FRACTION: - return Keys.JUMP_INTO_NUMERATOR; - } -} - export default function SharedKeys(props: Props) { const { onClickKey, diff --git a/packages/math-input/src/components/keypad/utils.ts b/packages/math-input/src/components/keypad/utils.ts new file mode 100644 index 0000000000..bb70945b18 --- /dev/null +++ b/packages/math-input/src/components/keypad/utils.ts @@ -0,0 +1,30 @@ +import Keys from "../../data/key-configs"; +import {CursorContext} from "../input/cursor-contexts"; + +// This is a helper function that returns the correct context for the cursor +// based on the cursorContext prop. It is used in the keypad to determine +// which key to render as the "jump out" key. +export function getCursorContextConfig( + cursorContext?: typeof CursorContext[keyof typeof CursorContext], +) { + if (!cursorContext) { + return null; + } + + switch (cursorContext) { + case CursorContext.NONE: + return null; + case CursorContext.IN_PARENS: + return Keys.JUMP_OUT_PARENTHESES; + case CursorContext.IN_SUPER_SCRIPT: + return Keys.JUMP_OUT_EXPONENT; + case CursorContext.IN_SUB_SCRIPT: + return Keys.JUMP_OUT_BASE; + case CursorContext.IN_NUMERATOR: + return Keys.JUMP_OUT_NUMERATOR; + case CursorContext.IN_DENOMINATOR: + return Keys.JUMP_OUT_DENOMINATOR; + case CursorContext.BEFORE_FRACTION: + return Keys.JUMP_INTO_NUMERATOR; + } +} diff --git a/packages/math-input/src/components/tabbar/types.ts b/packages/math-input/src/components/tabbar/types.ts index 2f8a52f5e0..a0f4bcc1cf 100644 --- a/packages/math-input/src/components/tabbar/types.ts +++ b/packages/math-input/src/components/tabbar/types.ts @@ -2,5 +2,6 @@ export type TabbarItemType = | "Geometry" | "Operators" | "Numbers" + | "Fractions" | "Extras" | "Dismiss"; diff --git a/packages/math-input/src/full-math-input.stories.tsx b/packages/math-input/src/full-math-input.stories.tsx index 22fecd75d6..633f1e2754 100644 --- a/packages/math-input/src/full-math-input.stories.tsx +++ b/packages/math-input/src/full-math-input.stories.tsx @@ -1,3 +1,4 @@ +import {INITIAL_VIEWPORTS} from "@storybook/addon-viewport"; import * as React from "react"; import type {KeypadAPI} from "./types"; @@ -6,6 +7,17 @@ import {KeypadInput, KeypadType, MobileKeypad} from "./index"; export default { title: "Full Mobile MathInput", + parameters: { + backgrounds: { + default: "light background", + values: [ + // We want a slightly darker default bg so that we can + // see the top of the keypad when it is open + {name: "light background", value: "lightgrey", default: true}, + ], + }, + viewport: {defaultViewport: "iphone6", viewports: INITIAL_VIEWPORTS}, + }, }; export const Basic = () => { @@ -13,9 +25,20 @@ export const Basic = () => { // Reference to the keypad const [keypadElement, setKeypadElement] = React.useState(); // Whether to use Expression or Fraction keypad - const [expression, setExpression] = React.useState(true); + const [expression, setExpression] = React.useState(false); // Whether to use v1 or v2 keypad - const [v2Keypad, setV2Keypad] = React.useState(false); + const [v2Keypad, setV2Keypad] = React.useState(true); + // Whether the keypad is open or not + const [keypadOpen, setKeypadOpen] = React.useState(false); + + const toggleKeypad = () => { + if (keypadOpen) { + keypadElement?.dismiss(); + } else { + keypadElement?.activate(); + } + setKeypadOpen(!keypadOpen); + }; React.useEffect(() => { keypadElement?.configure( @@ -30,14 +53,23 @@ export const Basic = () => { }, [keypadElement, expression]); return ( -
+
+ + NOTE: To properly test the input interaction, you will need + to simulate a device using the dev tools. + +