@khanacademy/math-input 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,6 +46,42 @@ describe("keypad", () => {
46
46
  });
47
47
  });
48
48
 
49
+ it("should snapshot unexpanded", () => {
50
+ // Arrange
51
+ // Act
52
+ const {container} = render(
53
+ <Keypad
54
+ onClickKey={() => {}}
55
+ preAlgebra
56
+ trigonometry
57
+ extraKeys={["PI"]}
58
+ onAnalyticsEvent={async () => {}}
59
+ expandedView={false}
60
+ />,
61
+ );
62
+
63
+ // Assert
64
+ expect(container).toMatchSnapshot("first render");
65
+ });
66
+
67
+ it("should snapshot expanded", () => {
68
+ // Arrange
69
+ // Act
70
+ const {container} = render(
71
+ <Keypad
72
+ onClickKey={() => {}}
73
+ preAlgebra
74
+ trigonometry
75
+ extraKeys={["PI"]}
76
+ onAnalyticsEvent={async () => {}}
77
+ expandedView={true}
78
+ />,
79
+ );
80
+
81
+ // Assert
82
+ expect(container).toMatchSnapshot("first render");
83
+ });
84
+
49
85
  it(`shows optional dismiss button`, () => {
50
86
  // Arrange
51
87
  // Act
@@ -122,4 +158,62 @@ describe("keypad", () => {
122
158
  // Assert
123
159
  expect(onClickKey).toHaveBeenCalledTimes(tabs.length);
124
160
  });
161
+
162
+ it(`does not show navigation pad with expanded view turned off`, () => {
163
+ // Arrange
164
+ // Act
165
+ render(
166
+ <Keypad
167
+ onClickKey={() => {}}
168
+ preAlgebra
169
+ trigonometry
170
+ extraKeys={["PI"]}
171
+ onAnalyticsEvent={async () => {}}
172
+ expandedView={false}
173
+ />,
174
+ );
175
+
176
+ // Assert
177
+ expect(
178
+ screen.queryByRole("button", {name: "Up arrow"}),
179
+ ).not.toBeInTheDocument();
180
+ expect(
181
+ screen.queryByRole("button", {name: "Right arrow"}),
182
+ ).not.toBeInTheDocument();
183
+ expect(
184
+ screen.queryByRole("button", {name: "Down arrow"}),
185
+ ).not.toBeInTheDocument();
186
+ expect(
187
+ screen.queryByRole("button", {name: "Left arrow"}),
188
+ ).not.toBeInTheDocument();
189
+ });
190
+
191
+ it(`shows navigation pad in expanded view`, () => {
192
+ // Arrange
193
+ // Act
194
+ render(
195
+ <Keypad
196
+ onClickKey={() => {}}
197
+ preAlgebra
198
+ trigonometry
199
+ extraKeys={["PI"]}
200
+ onAnalyticsEvent={async () => {}}
201
+ expandedView={true}
202
+ />,
203
+ );
204
+
205
+ // Assert
206
+ expect(
207
+ screen.getByRole("button", {name: "Up arrow"}),
208
+ ).toBeInTheDocument();
209
+ expect(
210
+ screen.getByRole("button", {name: "Right arrow"}),
211
+ ).toBeInTheDocument();
212
+ expect(
213
+ screen.getByRole("button", {name: "Down arrow"}),
214
+ ).toBeInTheDocument();
215
+ expect(
216
+ screen.getByRole("button", {name: "Left arrow"}),
217
+ ).toBeInTheDocument();
218
+ });
125
219
  });
@@ -1735,15 +1735,88 @@ export default function ButtonAsset({id}: Props): React.ReactElement {
1735
1735
  </svg>
1736
1736
  );
1737
1737
 
1738
+ case "UP":
1739
+ return (
1740
+ <svg width="48" height="48" viewBox="0 0 48 48">
1741
+ <g
1742
+ fill="none"
1743
+ fillRule="evenodd"
1744
+ transform="rotate(90 24 24)"
1745
+ >
1746
+ <path fill="none" d="M0 0h48v48H0z" />
1747
+ <path fill="none" d="M12 12h24v24H12z" />
1748
+ <path
1749
+ stroke="#21242C"
1750
+ strokeWidth="2"
1751
+ strokeLinecap="round"
1752
+ strokeLinejoin="round"
1753
+ d="M22 18l-6 6 6 6M16 24h16"
1754
+ />
1755
+ </g>
1756
+ </svg>
1757
+ );
1758
+ case "DOWN":
1759
+ return (
1760
+ <svg width="48" height="48" viewBox="0 0 48 48">
1761
+ <g
1762
+ fill="none"
1763
+ fillRule="evenodd"
1764
+ transform="rotate(270 24 24)"
1765
+ >
1766
+ <path fill="none" d="M0 0h48v48H0z" />
1767
+ <path fill="none" d="M12 12h24v24H12z" />
1768
+ <path
1769
+ stroke="#21242C"
1770
+ strokeWidth="2"
1771
+ strokeLinecap="round"
1772
+ strokeLinejoin="round"
1773
+ d="M22 18l-6 6 6 6M16 24h16"
1774
+ />
1775
+ </g>
1776
+ </svg>
1777
+ );
1778
+ case "LEFT":
1779
+ return (
1780
+ <svg width="48" height="48" viewBox="0 0 48 48">
1781
+ <g fill="none" fillRule="evenodd">
1782
+ <path fill="none" d="M0 0h48v48H0z" />
1783
+ <path fill="none" d="M12 12h24v24H12z" />
1784
+ <path
1785
+ stroke="#21242C"
1786
+ strokeWidth="2"
1787
+ strokeLinecap="round"
1788
+ strokeLinejoin="round"
1789
+ d="M22 18l-6 6 6 6M16 24h16"
1790
+ />
1791
+ </g>
1792
+ </svg>
1793
+ );
1794
+ case "RIGHT":
1795
+ return (
1796
+ <svg width="48" height="48" viewBox="0 0 48 48">
1797
+ <g
1798
+ fill="none"
1799
+ fillRule="evenodd"
1800
+ transform="rotate(180 24 24)"
1801
+ >
1802
+ <path fill="none" d="M0 0h48v48H0z" />
1803
+ <path fill="none" d="M12 12h24v24H12z" />
1804
+ <path
1805
+ stroke="#21242C"
1806
+ strokeWidth="2"
1807
+ strokeLinecap="round"
1808
+ strokeLinejoin="round"
1809
+ d="M22 18l-6 6 6 6M16 24h16"
1810
+ />
1811
+ </g>
1812
+ </svg>
1813
+ );
1814
+
1738
1815
  /**
1739
1816
  * ANYTHING BELOW IS NOT YET HANDLED
1740
1817
  */
1741
1818
  case "MANY":
1742
1819
  case "NOOP":
1743
- case "UP":
1744
- case "DOWN":
1745
- case "LEFT":
1746
- case "RIGHT":
1747
1820
  case "PHI":
1748
1821
  case "NTHROOT3":
1749
1822
  case "POW":
@@ -73,7 +73,7 @@ const styles = StyleSheet.create({
73
73
  display: "flex",
74
74
  justifyContent: "center",
75
75
  alignItems: "center",
76
- boxShadow: "0px 1px 0px rgba(33, 36, 44, 0.32)",
76
+ boxShadow: `0px 1px 0px ${Color.offBlack32}`,
77
77
  boxSizing: "border-box",
78
78
  background: Color.white,
79
79
  borderRadius: 4,
@@ -31,6 +31,7 @@ export default {
31
31
  preAlgebra: false,
32
32
  trigonometry: false,
33
33
  sendEvent: () => {},
34
+ onAnalyticsEvent: async () => {},
34
35
  },
35
36
  argTypes: {
36
37
  advancedRelations: {
@@ -115,4 +116,7 @@ Everything.args = {
115
116
  multiplicationDot: false,
116
117
  preAlgebra: true,
117
118
  trigonometry: true,
119
+ expandedView: true,
120
+ showDismiss: true,
121
+ extraKeys: ["a", "b", "c"],
118
122
  };
@@ -11,7 +11,9 @@ import FractionsPage from "./keypad-pages/fractions-page";
11
11
  import GeometryPage from "./keypad-pages/geometry-page";
12
12
  import NumbersPage from "./keypad-pages/numbers-page";
13
13
  import OperatorsPage from "./keypad-pages/operators-page";
14
+ import NavigationPad from "./navigation-pad";
14
15
  import SharedKeys from "./shared-keys";
16
+ import {expandedViewThreshold} from "./utils";
15
17
 
16
18
  import type Key from "../../data/keys";
17
19
  import type {ClickKeyCallback} from "../../types";
@@ -23,10 +25,10 @@ export type Props = {
23
25
  extraKeys: ReadonlyArray<Key>;
24
26
  cursorContext?: typeof CursorContext[keyof typeof CursorContext];
25
27
  showDismiss?: boolean;
28
+ expandedView?: boolean;
26
29
 
27
30
  multiplicationDot?: boolean;
28
31
  divisionKey?: boolean;
29
-
30
32
  trigonometry?: boolean;
31
33
  preAlgebra?: boolean;
32
34
  logarithms?: boolean;
@@ -96,6 +98,7 @@ export default function Keypad(props: Props) {
96
98
  showDismiss,
97
99
  onAnalyticsEvent,
98
100
  fractionsOnly,
101
+ expandedView,
99
102
  } = props;
100
103
 
101
104
  // Use a different grid for our fraction keypad
@@ -128,58 +131,70 @@ export default function Keypad(props: Props) {
128
131
  }, [onAnalyticsEvent, isMounted]);
129
132
 
130
133
  return (
131
- <View>
132
- <Tabbar
133
- items={availableTabs}
134
- selectedItem={selectedPage}
135
- onSelectItem={(tabbarItem: TabbarItemType) => {
136
- setSelectedPage(tabbarItem);
137
- }}
138
- style={styles.tabbar}
139
- onClickClose={
140
- showDismiss ? () => onClickKey("DISMISS") : undefined
141
- }
142
- />
143
-
134
+ <View style={expandedView ? styles.keypadOuterContainer : null}>
144
135
  <View
145
- style={[styles.keypadGrid, gridStyle]}
146
- role="grid"
147
- tabIndex={0}
148
- aria-label="Keypad"
136
+ style={[
137
+ styles.wrapper,
138
+ expandedView ? styles.expandedWrapper : null,
139
+ ]}
149
140
  >
150
- {selectedPage === "Fractions" && (
151
- <FractionsPage
152
- onClickKey={onClickKey}
153
- cursorContext={cursorContext}
154
- />
155
- )}
156
- {selectedPage === "Numbers" && (
157
- <NumbersPage onClickKey={onClickKey} />
158
- )}
159
- {selectedPage === "Extras" && (
160
- <ExtrasPage onClickKey={onClickKey} extraKeys={extraKeys} />
161
- )}
162
- {selectedPage === "Operators" && (
163
- <OperatorsPage
164
- onClickKey={onClickKey}
165
- preAlgebra={preAlgebra}
166
- logarithms={logarithms}
167
- basicRelations={basicRelations}
168
- advancedRelations={advancedRelations}
169
- />
170
- )}
171
- {selectedPage === "Geometry" && (
172
- <GeometryPage onClickKey={onClickKey} />
173
- )}
174
- {!fractionsOnly && (
175
- <SharedKeys
176
- onClickKey={onClickKey}
177
- cursorContext={cursorContext}
178
- multiplicationDot={multiplicationDot}
179
- divisionKey={divisionKey}
180
- selectedPage={selectedPage}
181
- />
182
- )}
141
+ <Tabbar
142
+ items={availableTabs}
143
+ selectedItem={selectedPage}
144
+ onSelectItem={(tabbarItem: TabbarItemType) => {
145
+ setSelectedPage(tabbarItem);
146
+ }}
147
+ onClickClose={
148
+ showDismiss ? () => onClickKey("DISMISS") : undefined
149
+ }
150
+ />
151
+
152
+ <View style={styles.keypadInnerContainer}>
153
+ <View
154
+ style={[styles.keypadGrid, gridStyle]}
155
+ role="grid"
156
+ tabIndex={0}
157
+ aria-label="Keypad"
158
+ >
159
+ {selectedPage === "Fractions" && (
160
+ <FractionsPage
161
+ onClickKey={onClickKey}
162
+ cursorContext={cursorContext}
163
+ />
164
+ )}
165
+ {selectedPage === "Numbers" && (
166
+ <NumbersPage onClickKey={onClickKey} />
167
+ )}
168
+ {selectedPage === "Extras" && (
169
+ <ExtrasPage
170
+ onClickKey={onClickKey}
171
+ extraKeys={extraKeys}
172
+ />
173
+ )}
174
+ {selectedPage === "Operators" && (
175
+ <OperatorsPage
176
+ onClickKey={onClickKey}
177
+ preAlgebra={preAlgebra}
178
+ logarithms={logarithms}
179
+ basicRelations={basicRelations}
180
+ advancedRelations={advancedRelations}
181
+ />
182
+ )}
183
+ {selectedPage === "Geometry" && (
184
+ <GeometryPage onClickKey={onClickKey} />
185
+ )}
186
+ {!fractionsOnly && (
187
+ <SharedKeys
188
+ onClickKey={onClickKey}
189
+ cursorContext={cursorContext}
190
+ multiplicationDot={multiplicationDot}
191
+ divisionKey={divisionKey}
192
+ selectedPage={selectedPage}
193
+ />
194
+ )}
195
+ </View>
196
+ {expandedView && <NavigationPad onClickKey={onClickKey} />}
197
+ </View>
183
198
  </View>
184
199
  </View>
185
200
  );
@@ -188,13 +203,28 @@ export default function Keypad(props: Props) {
188
203
  Keypad.defaultProps = defaultProps;
189
204
 
190
205
  const styles = StyleSheet.create({
191
- tabbar: {
206
+ keypadOuterContainer: {
207
+ display: "flex",
208
+ alignItems: "center",
209
+ },
210
+ wrapper: {
192
211
  background: Color.white,
193
212
  },
213
+ expandedWrapper: {
214
+ borderWidth: "1px 1px 0 1px",
215
+ borderColor: Color.offBlack32,
216
+ maxWidth: expandedViewThreshold,
217
+ borderRadius: "3px 3px 0 0",
218
+ },
219
+ keypadInnerContainer: {
220
+ display: "flex",
221
+ flexDirection: "row",
222
+ backgroundColor: "#DBDCDD",
223
+ },
194
224
  keypadGrid: {
195
225
  display: "grid",
196
226
  gridTemplateRows: "repeat(4, 1fr)",
197
- backgroundColor: "#DBDCDD",
227
+ flex: 1,
198
228
  },
199
229
  expressionGrid: {
200
230
  gridTemplateColumns: "repeat(6, 1fr)",
@@ -4,6 +4,8 @@ import ReactDOM from "react-dom";
4
4
 
5
5
  import {View} from "../../fake-react-native-web/index";
6
6
 
7
+ import {expandedViewThreshold} from "./utils";
8
+
7
9
  import type Key from "../../data/keys";
8
10
  import type {
9
11
  Cursor,
@@ -34,18 +36,68 @@ type Props = {
34
36
 
35
37
  type State = {
36
38
  active: boolean;
39
+ containerWidth: number;
37
40
  keypadConfig?: KeypadConfiguration;
38
41
  keyHandler?: KeyHandler;
39
42
  cursor?: Cursor;
40
43
  };
41
44
 
42
45
  class MobileKeypad extends React.Component<Props, State> implements KeypadAPI {
46
+ _containerRef = React.createRef<HTMLDivElement>();
47
+ _containerResizeObserver: ResizeObserver | null = null;
48
+ _throttleResize = false;
43
49
  hasMounted = false;
44
50
 
45
51
  state: State = {
52
+ containerWidth: 0,
46
53
  active: false,
47
54
  };
48
55
 
56
+ componentDidMount() {
57
+ this._resize();
58
+
59
+ window.addEventListener("resize", this._throttleResizeHandler);
60
+ window.addEventListener(
61
+ "orientationchange",
62
+ this._throttleResizeHandler,
63
+ );
64
+
65
+ this._containerResizeObserver = new ResizeObserver(
66
+ this._throttleResizeHandler,
67
+ );
68
+
69
+ if (this._containerRef.current) {
70
+ this._containerResizeObserver.observe(this._containerRef.current);
71
+ }
72
+ }
73
+
74
+ componentWillUnMount() {
75
+ window.removeEventListener("resize", this._throttleResizeHandler);
76
+ window.removeEventListener(
77
+ "orientationchange",
78
+ this._throttleResizeHandler,
79
+ );
80
+ this._containerResizeObserver?.disconnect();
81
+ }
82
+
83
+ _resize = () => {
84
+ const containerWidth = this._containerRef.current?.clientWidth || 0;
85
+ this.setState({containerWidth});
86
+ };
87
+
88
+ _throttleResizeHandler = () => {
89
+ if (this._throttleResize) {
90
+ return;
91
+ }
92
+
93
+ this._throttleResize = true;
94
+
95
+ setTimeout(() => {
96
+ this._resize();
97
+ this._throttleResize = false;
98
+ }, 100);
99
+ };
100
+
49
101
  activate: () => void = () => {
50
102
  this.setState({active: true});
51
103
  };
@@ -98,7 +150,7 @@ class MobileKeypad extends React.Component<Props, State> implements KeypadAPI {
98
150
 
99
151
  render(): React.ReactNode {
100
152
  const {style} = this.props;
101
- const {active, cursor, keypadConfig} = this.state;
153
+ const {active, containerWidth, cursor, keypadConfig} = this.state;
102
154
 
103
155
  const containerStyle = [
104
156
  // internal styles
@@ -113,6 +165,7 @@ class MobileKeypad extends React.Component<Props, State> implements KeypadAPI {
113
165
  return (
114
166
  <View
115
167
  style={containerStyle}
168
+ forwardRef={this._containerRef}
116
169
  ref={(element) => {
117
170
  if (!this.hasMounted && element) {
118
171
  // TODO(matthewc)[LC-1081]: clean up this weird
@@ -150,6 +203,7 @@ class MobileKeypad extends React.Component<Props, State> implements KeypadAPI {
150
203
  logarithms={isExpression}
151
204
  basicRelations={isExpression}
152
205
  advancedRelations={isExpression}
206
+ expandedView={containerWidth > expandedViewThreshold}
153
207
  showDismiss
154
208
  />
155
209
  </View>
@@ -0,0 +1,127 @@
1
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
2
+ import Color from "@khanacademy/wonder-blocks-color";
3
+ import {View} from "@khanacademy/wonder-blocks-core";
4
+ import {StyleSheet} from "aphrodite";
5
+ import * as React from "react";
6
+
7
+ import ButtonAsset from "./button-assets";
8
+
9
+ import type Key from "../../data/keys";
10
+ import type {KeyConfig, ClickKeyCallback} from "../../types";
11
+
12
+ export type KeypadButtonProps = {
13
+ // 0 indexed [x, y] position in keypad CSS grid
14
+ coord: readonly [number, number];
15
+ keyConfig: KeyConfig;
16
+ onClickKey: ClickKeyCallback;
17
+ };
18
+
19
+ function getStyles(key: Key) {
20
+ switch (key) {
21
+ case "UP":
22
+ return styles.up;
23
+ case "RIGHT":
24
+ return styles.right;
25
+ case "DOWN":
26
+ return styles.down;
27
+ case "LEFT":
28
+ return styles.left;
29
+ default:
30
+ throw new Error(`Invalid key: ${key}`);
31
+ }
32
+ }
33
+
34
+ export default function NavigationButton({
35
+ coord,
36
+ keyConfig,
37
+ onClickKey,
38
+ }: KeypadButtonProps) {
39
+ const key = keyConfig.id;
40
+ const directionalStyles = getStyles(key);
41
+
42
+ return (
43
+ <View
44
+ style={{
45
+ gridColumn: coord[0] + 1,
46
+ gridRow: coord[1] + 1,
47
+ }}
48
+ >
49
+ <Clickable
50
+ onClick={(e) => onClickKey(keyConfig.id, e)}
51
+ style={styles.clickable}
52
+ aria-label={keyConfig.ariaLabel}
53
+ >
54
+ {({hovered, focused, pressed}) => (
55
+ <View style={styles.outerBoxBase}>
56
+ <View
57
+ style={[
58
+ styles.base,
59
+ directionalStyles,
60
+ hovered && styles.hovered,
61
+ focused && styles.focused,
62
+ pressed && styles.pressed,
63
+ ]}
64
+ >
65
+ <ButtonAsset id={keyConfig.id} />
66
+ </View>
67
+ </View>
68
+ )}
69
+ </Clickable>
70
+ </View>
71
+ );
72
+ }
73
+
74
+ const borderRadiusPx = 4;
75
+
76
+ const styles = StyleSheet.create({
77
+ clickable: {
78
+ width: "100%",
79
+ height: "100%",
80
+
81
+ ":focus": {
82
+ outline: `none`,
83
+ },
84
+ },
85
+ outerBoxBase: {
86
+ height: "100%",
87
+ width: "100%",
88
+ },
89
+ base: {
90
+ boxShadow: `0px 1px 0px ${Color.offBlack32}`,
91
+ display: "flex",
92
+ justifyContent: "center",
93
+ alignItems: "center",
94
+ background: Color.white,
95
+ borderWidth: 2,
96
+ borderColor: Color.white,
97
+ },
98
+ up: {
99
+ borderTopLeftRadius: borderRadiusPx,
100
+ borderTopRightRadius: borderRadiusPx,
101
+ },
102
+ right: {
103
+ borderTopRightRadius: borderRadiusPx,
104
+ borderBottomRightRadius: borderRadiusPx,
105
+ },
106
+ down: {
107
+ borderBottomLeftRadius: borderRadiusPx,
108
+ borderBottomRightRadius: borderRadiusPx,
109
+ },
110
+ left: {
111
+ borderTopLeftRadius: borderRadiusPx,
112
+ borderBottomLeftRadius: borderRadiusPx,
113
+ },
114
+ hovered: {
115
+ borderColor: Color.blue,
116
+ boxShadow: "none",
117
+ },
118
+ focused: {
119
+ borderColor: Color.blue,
120
+ boxShadow: "none",
121
+ },
122
+ pressed: {
123
+ border: "2px solid #1B50B3",
124
+ background: `linear-gradient(0deg, rgba(24, 101, 242, 0.32), rgba(24, 101, 242, 0.32)), ${Color.white}`,
125
+ boxShadow: "none",
126
+ },
127
+ });
@@ -0,0 +1,25 @@
1
+ import * as React from "react";
2
+
3
+ import NavigationPad from "./navigation-pad";
4
+
5
+ export default {
6
+ title: "MathInput v2 Navigation Pad",
7
+ parameters: {
8
+ backgrounds: {
9
+ default: "light background",
10
+ values: [
11
+ // We want a slightly darker default bg so that we can
12
+ // see the top of the keypad when it is open
13
+ {name: "light background", value: "lightgrey", default: true},
14
+ ],
15
+ },
16
+ },
17
+ };
18
+
19
+ export function basic() {
20
+ return (
21
+ <div style={{padding: 50}}>
22
+ <NavigationPad onClickKey={() => {}} />
23
+ </div>
24
+ );
25
+ }