Skip to content

Commit c9e06ac

Browse files
committed
feat: view switcher component
Avoid deep property drilling by using the view switching context. The context displays one of the previously configured screens.
1 parent d04bf92 commit c9e06ac

24 files changed

+283
-196
lines changed

packages/keybr-widget/lib/components/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ export * from "./textfield/index.ts";
2121
export * from "./toast/index.ts";
2222
export * from "./tour/index.ts";
2323
export * from "./types.ts";
24+
export * from "./view/index.ts";
2425
export * from "./zoomer/index.ts";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { test } from "node:test";
2+
import { render } from "@testing-library/react";
3+
import { userEvent } from "@testing-library/user-event";
4+
import { assert } from "chai";
5+
import { useView, ViewSwitch } from "./ViewSwitch.tsx";
6+
7+
const views = {
8+
one: One,
9+
two: Two,
10+
invalid: Invalid,
11+
} as const;
12+
13+
test("switch", async () => {
14+
const r = render(<ViewSwitch views={views} />);
15+
16+
assert.isNotNull(r.queryByText("One"));
17+
assert.isNull(r.queryByText("Two"));
18+
19+
await userEvent.click(r.getByText("Switch"));
20+
21+
assert.isNotNull(r.queryByText("Two"));
22+
assert.isNotNull(r.queryByText("Due"));
23+
assert.isNull(r.queryByText("One"));
24+
assert.isNull(r.queryByText("Uno"));
25+
26+
await userEvent.click(r.getByText("Switch"));
27+
28+
assert.isNotNull(r.queryByText("One"));
29+
assert.isNotNull(r.queryByText("Uno"));
30+
assert.isNull(r.queryByText("Two"));
31+
assert.isNull(r.queryByText("Due"));
32+
33+
r.unmount();
34+
});
35+
36+
function One({ name }: { name?: string } = {}) {
37+
const { setView } = useView(views);
38+
return (
39+
<div>
40+
<p>One</p>
41+
<p>{name}</p>
42+
<button
43+
onClick={() => {
44+
setView("two", { name: "Due" });
45+
}}
46+
>
47+
Switch
48+
</button>
49+
</div>
50+
);
51+
}
52+
53+
function Two({ name }: { name?: string } = {}) {
54+
const { setView } = useView(views);
55+
return (
56+
<div>
57+
<p>Two</p>
58+
<p>{name}</p>
59+
<button
60+
onClick={() => {
61+
setView("one", { name: "Uno" });
62+
}}
63+
>
64+
Switch
65+
</button>
66+
</div>
67+
);
68+
}
69+
70+
function Invalid() {
71+
const { setView } = useView(views);
72+
return (
73+
<div>
74+
<button
75+
onClick={() => {
76+
// @ts-expect-error Invalid view name.
77+
setView("omg");
78+
}}
79+
>
80+
Switch
81+
</button>
82+
</div>
83+
);
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
createContext,
3+
type FunctionComponent,
4+
useContext,
5+
useState,
6+
} from "react";
7+
8+
export type ViewName = string;
9+
export type ViewProps = Record<string, any>;
10+
export type ViewMap = { readonly [name: ViewName]: FunctionComponent<any> };
11+
12+
export const ViewContext = createContext<{
13+
readonly setView: (name: ViewName, props?: ViewProps) => void;
14+
}>({
15+
setView: (name) => {
16+
if (process.env.NODE_ENV !== "production") {
17+
console.log(`Switch view to [${name}]`);
18+
}
19+
},
20+
});
21+
22+
export function useView<T extends ViewMap>(views: T) {
23+
return useContext(ViewContext) as {
24+
readonly setView: (name: keyof T, props?: ViewProps) => void;
25+
};
26+
}
27+
28+
export function ViewSwitch({ views }: { readonly views: ViewMap }) {
29+
const [[name, props], setView] = useState(
30+
() => [Object.keys(views)[0], {}] as [ViewName, ViewProps],
31+
);
32+
const View = views[name];
33+
if (View == null) {
34+
throw new Error(
35+
process.env.NODE_ENV !== "production"
36+
? `Unknown view [${name}]`
37+
: undefined,
38+
);
39+
}
40+
return (
41+
<ViewContext.Provider
42+
value={{
43+
setView: (name, props = {}) => {
44+
setView([name, props]);
45+
},
46+
}}
47+
>
48+
<View {...props} />
49+
</ViewContext.Provider>
50+
);
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./ViewSwitch.tsx";
+6-26
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { KeyboardOptions, Layout } from "@keybr/keyboard";
2-
import { Settings, useSettings } from "@keybr/settings";
3-
import { type ReactNode, useState } from "react";
4-
import { PracticeScreen } from "./practice/PracticeScreen.tsx";
5-
import { SettingsScreen } from "./settings/SettingsScreen.tsx";
2+
import { Settings } from "@keybr/settings";
3+
import { ViewSwitch } from "@keybr/widget";
4+
import { views } from "./views.tsx";
65

76
setDefaultLayout(window.navigator.language);
87

9-
function setDefaultLayout(localeId: string): void {
8+
function setDefaultLayout(localeId: string) {
109
const layout = Layout.findLayout(localeId);
1110
if (layout != null) {
1211
Settings.addDefaults(
@@ -18,25 +17,6 @@ function setDefaultLayout(localeId: string): void {
1817
}
1918
}
2019

21-
export function PracticePage(): ReactNode {
22-
const { updateSettings } = useSettings();
23-
const [configure, setConfigure] = useState(false);
24-
if (configure) {
25-
return (
26-
<SettingsScreen
27-
onSubmit={(newSettings) => {
28-
updateSettings(newSettings);
29-
setConfigure(false);
30-
}}
31-
/>
32-
);
33-
} else {
34-
return (
35-
<PracticeScreen
36-
onConfigure={() => {
37-
setConfigure(true);
38-
}}
39-
/>
40-
);
41-
}
20+
export function PracticePage() {
21+
return <ViewSwitch views={views} />;
4222
}

packages/page-practice/lib/practice/Controller.tsx

-3
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,9 @@ import {
2828
export const Controller = memo(function Controller({
2929
progress,
3030
onResult,
31-
onConfigure,
3231
}: {
3332
readonly progress: Progress;
3433
readonly onResult: (result: Result) => void;
35-
readonly onConfigure: () => void;
3634
}): ReactNode {
3735
const {
3836
state,
@@ -60,7 +58,6 @@ export const Controller = memo(function Controller({
6058
onKeyDown={handleKeyDown}
6159
onKeyUp={handleKeyUp}
6260
onInput={handleInput}
63-
onConfigure={onConfigure}
6461
/>
6562
);
6663
});

packages/page-practice/lib/practice/Controls.tsx

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Dir } from "@keybr/intl";
22
import { names } from "@keybr/lesson-ui";
3-
import { Button, Icon, IconButton } from "@keybr/widget";
3+
import { Button, Icon, IconButton, useView } from "@keybr/widget";
44
import {
55
mdiAspectRatio,
66
mdiCog,
@@ -10,22 +10,22 @@ import {
1010
} from "@mdi/js";
1111
import { memo, type ReactNode } from "react";
1212
import { useIntl } from "react-intl";
13+
import { views } from "../views.tsx";
1314
import * as styles from "./Controls.module.less";
1415

1516
export const Controls = memo(function Controls({
1617
onChangeView,
1718
onResetLesson,
1819
onSkipLesson,
1920
onHelp,
20-
onConfigure,
2121
}: {
2222
readonly onChangeView: () => void;
2323
readonly onResetLesson: () => void;
2424
readonly onSkipLesson: () => void;
2525
readonly onHelp: () => void;
26-
readonly onConfigure: () => void;
2726
}): ReactNode {
2827
const { formatMessage } = useIntl();
28+
const { setView } = useView(views);
2929
return (
3030
<div id={names.controls} className={styles.controls}>
3131
<IconButton
@@ -73,7 +73,9 @@ export const Controls = memo(function Controls({
7373
defaultMessage:
7474
"Change lesson settings, configure language, keyboard layout, etc.",
7575
})}
76-
onClick={onConfigure}
76+
onClick={() => {
77+
setView("settings");
78+
}}
7779
/>
7880
</div>
7981
);

packages/page-practice/lib/practice/PracticeScreen.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ test("render", async () => {
2222
.set(lessonProps.customText.content, "abcdefghij")}
2323
>
2424
<FakeResultContext initialResults={faker.nextResultList(100)}>
25-
<PracticeScreen onConfigure={() => {}} />
25+
<PracticeScreen />
2626
</FakeResultContext>
2727
</FakeSettingsContext>
2828
</FakeIntlProvider>,

packages/page-practice/lib/practice/PracticeScreen.tsx

+4-17
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,21 @@ import { LessonLoader } from "@keybr/lesson-loader";
66
import { LoadingProgress } from "@keybr/pages-shared";
77
import { type Result, useResults } from "@keybr/result";
88
import { useSettings } from "@keybr/settings";
9-
import { type ReactNode, useEffect, useMemo, useState } from "react";
9+
import { useEffect, useMemo, useState } from "react";
1010
import { Controller } from "./Controller.tsx";
1111
import { displayEvent, Progress } from "./state/index.ts";
1212

13-
export function PracticeScreen({
14-
onConfigure,
15-
}: {
16-
readonly onConfigure: () => void;
17-
}): ReactNode {
13+
export function PracticeScreen() {
1814
return (
1915
<KeyboardProvider>
2016
<LessonLoader>
21-
{(lesson) => (
22-
<ProgressUpdater lesson={lesson} onConfigure={onConfigure} />
23-
)}
17+
{(lesson) => <ProgressUpdater lesson={lesson} />}
2418
</LessonLoader>
2519
</KeyboardProvider>
2620
);
2721
}
2822

29-
function ProgressUpdater({
30-
lesson,
31-
onConfigure,
32-
}: {
33-
readonly lesson: Lesson;
34-
readonly onConfigure: () => void;
35-
}): ReactNode {
23+
function ProgressUpdater({ lesson }: { readonly lesson: Lesson }) {
3624
const { results, appendResults } = useResults();
3725
const [progress, { total, current }] = useProgress(lesson, results);
3826
if (progress == null) {
@@ -47,7 +35,6 @@ function ProgressUpdater({
4735
appendResults([result]);
4836
}
4937
}}
50-
onConfigure={onConfigure}
5138
/>
5239
);
5340
}

packages/page-practice/lib/practice/Presenter.tsx

+1-5
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ type Props = {
2727
readonly onKeyDown: (ev: IKeyboardEvent) => void;
2828
readonly onKeyUp: (ev: IKeyboardEvent) => void;
2929
readonly onInput: (ev: IInputEvent) => void;
30-
readonly onConfigure: () => void;
3130
};
3231

3332
type State = {
@@ -75,7 +74,7 @@ export class Presenter extends PureComponent<Props, State> {
7574

7675
override render(): ReactNode {
7776
const {
78-
props: { state, lines, depressedKeys, onConfigure },
77+
props: { state, lines, depressedKeys },
7978
state: { view, tour, focus },
8079
handleResetLesson,
8180
handleSkipLesson,
@@ -102,7 +101,6 @@ export class Presenter extends PureComponent<Props, State> {
102101
onResetLesson={handleResetLesson}
103102
onSkipLesson={handleSkipLesson}
104103
onHelp={handleHelp}
105-
onConfigure={onConfigure}
106104
/>
107105
}
108106
textInput={
@@ -136,7 +134,6 @@ export class Presenter extends PureComponent<Props, State> {
136134
onResetLesson={handleResetLesson}
137135
onSkipLesson={handleSkipLesson}
138136
onHelp={handleHelp}
139-
onConfigure={onConfigure}
140137
/>
141138
}
142139
textInput={
@@ -169,7 +166,6 @@ export class Presenter extends PureComponent<Props, State> {
169166
onResetLesson={handleResetLesson}
170167
onSkipLesson={handleSkipLesson}
171168
onHelp={handleHelp}
172-
onConfigure={onConfigure}
173169
/>
174170
}
175171
textInput={

packages/page-practice/lib/settings/SettingsScreen.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ test("render", async () => {
1717
<FakeIntlProvider>
1818
<FakeSettingsContext>
1919
<FakeResultContext initialResults={faker.nextResultList(100)}>
20-
<SettingsScreen onSubmit={() => {}} />
20+
<SettingsScreen />
2121
</FakeResultContext>
2222
</FakeSettingsContext>
2323
</FakeIntlProvider>,

0 commit comments

Comments
 (0)