Skip to content

Commit 5fdc23e

Browse files
committed
feat: implement the mouse hover in and out events
1 parent 7af6f1d commit 5fdc23e

File tree

11 files changed

+271
-121
lines changed

11 files changed

+271
-121
lines changed

packages/keybr-keyboard-ui/lib/KeyLayer.test.tsx

+37
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test } from "node:test";
22
import { KeyboardContext, Layout, loadKeyboard } from "@keybr/keyboard";
33
import { render } from "@testing-library/react";
4+
import { userEvent } from "@testing-library/user-event";
45
import { assert } from "chai";
56
import { KeyLayer } from "./KeyLayer.tsx";
67

@@ -38,3 +39,39 @@ test("update", () => {
3839

3940
r.unmount();
4041
});
42+
43+
test("events", async () => {
44+
const keyboard = loadKeyboard(Layout.EN_US);
45+
46+
const events: string[] = [];
47+
48+
const r = render(
49+
<KeyboardContext.Provider value={keyboard}>
50+
<KeyLayer
51+
onKeyHoverIn={(key) => {
52+
events.push(`hover in ${key}`);
53+
}}
54+
onKeyHoverOut={(key) => {
55+
events.push(`hover out ${key}`);
56+
}}
57+
onKeyClick={(key) => {
58+
events.push(`click ${key}`);
59+
}}
60+
/>
61+
</KeyboardContext.Provider>,
62+
);
63+
64+
events.length = 0;
65+
await userEvent.hover(r.getByText("A"));
66+
assert.deepStrictEqual(events, ["hover in KeyA"]);
67+
68+
events.length = 0;
69+
await userEvent.unhover(r.getByText("A"));
70+
assert.deepStrictEqual(events, ["hover out KeyA"]);
71+
72+
events.length = 0;
73+
await userEvent.click(r.getByText("A"));
74+
assert.deepStrictEqual(events, ["hover in KeyA", "click KeyA"]);
75+
76+
r.unmount();
77+
});

packages/keybr-keyboard-ui/lib/KeyLayer.tsx

+40-3
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
type ComponentType,
99
memo,
1010
type ReactElement,
11-
type ReactNode,
1211
useMemo,
12+
useRef,
1313
} from "react";
1414
import { type KeyProps, makeKeyComponent } from "./Key.tsx";
1515
import { Surface } from "./shapes.tsx";
@@ -18,22 +18,59 @@ export const KeyLayer = memo(function KeyLayer({
1818
depressedKeys = [],
1919
toggledKeys = [],
2020
showColors = false,
21+
onKeyHoverIn,
22+
onKeyHoverOut,
23+
onKeyClick,
2124
}: {
2225
readonly depressedKeys?: readonly KeyId[];
2326
readonly toggledKeys?: readonly KeyId[];
2427
readonly showColors?: boolean;
25-
}): ReactNode {
28+
readonly onKeyHoverIn?: (key: KeyId, elem: Element) => void;
29+
readonly onKeyHoverOut?: (key: KeyId, elem: Element) => void;
30+
readonly onKeyClick?: (key: KeyId, elem: Element) => void;
31+
}) {
2632
const keyboard = useKeyboard();
33+
const svgRef = useRef<SVGSVGElement>(null);
2734
const children = useMemo(() => getKeyElements(keyboard), [keyboard]);
2835
return (
29-
<Surface>
36+
<Surface
37+
ref={svgRef}
38+
onMouseOver={(event) => {
39+
relayEvent(svgRef.current!, event, onKeyHoverIn);
40+
}}
41+
onMouseOut={(event) => {
42+
relayEvent(svgRef.current!, event, onKeyHoverOut);
43+
}}
44+
onClick={(event) => {
45+
relayEvent(svgRef.current!, event, onKeyClick);
46+
}}
47+
>
3048
{children.map((child) =>
3149
child.select(depressedKeys, toggledKeys, showColors),
3250
)}
3351
</Surface>
3452
);
3553
});
3654

55+
function relayEvent(
56+
root: Element,
57+
{ target }: { readonly target: any },
58+
handler?: (key: KeyId, elem: Element) => void,
59+
) {
60+
while (
61+
handler != null &&
62+
target instanceof Element &&
63+
root.contains(target)
64+
) {
65+
const key = (target as SVGElement).dataset["key"];
66+
if (key) {
67+
handler(key, target);
68+
return;
69+
}
70+
target = target.parentElement;
71+
}
72+
}
73+
3774
function getKeyElements(keyboard: Keyboard): MemoizedKeyElements[] {
3875
return [...keyboard.shapes.values()].map(
3976
(shape) => new MemoizedKeyElements(keyboard, shape),

packages/keybr-lesson-ui/lib/Key.test.tsx

+39-33
Original file line numberDiff line numberDiff line change
@@ -8,70 +8,76 @@ import { assert } from "chai";
88
import { Key } from "./Key.tsx";
99

1010
test("render excluded", () => {
11+
const key = new LessonKey({
12+
letter: FakePhoneticModel.letter1,
13+
samples: [],
14+
timeToType: null,
15+
bestTimeToType: null,
16+
confidence: null,
17+
bestConfidence: null,
18+
}).asExcluded();
19+
1120
const r = render(
1221
<FakeIntlProvider>
1322
<FakeSettingsContext>
14-
<Key
15-
lessonKey={new LessonKey({
16-
letter: FakePhoneticModel.letter1,
17-
samples: [],
18-
timeToType: null,
19-
bestTimeToType: null,
20-
confidence: null,
21-
bestConfidence: null,
22-
}).asExcluded()}
23-
/>
23+
<Key lessonKey={key} />
2424
</FakeSettingsContext>
2525
</FakeIntlProvider>,
2626
);
2727

28-
assert.isNotNull(r.container.querySelector(".lessonKey_excluded"));
28+
const elem = r.container.querySelector(".lessonKey_excluded");
29+
assert.isNotNull(elem);
30+
assert.strictEqual(Key.attached(elem), key);
2931

3032
r.unmount();
3133
});
3234

3335
test("render included", () => {
36+
const key = new LessonKey({
37+
letter: FakePhoneticModel.letter1,
38+
samples: [],
39+
timeToType: null,
40+
bestTimeToType: null,
41+
confidence: null,
42+
bestConfidence: null,
43+
}).asIncluded();
44+
3445
const r = render(
3546
<FakeIntlProvider>
3647
<FakeSettingsContext>
37-
<Key
38-
lessonKey={new LessonKey({
39-
letter: FakePhoneticModel.letter1,
40-
samples: [],
41-
timeToType: null,
42-
bestTimeToType: null,
43-
confidence: null,
44-
bestConfidence: null,
45-
}).asIncluded()}
46-
/>
48+
<Key lessonKey={key} />
4749
</FakeSettingsContext>
4850
</FakeIntlProvider>,
4951
);
5052

51-
assert.isNotNull(r.container.querySelector(".lessonKey_included"));
53+
const elem = r.container.querySelector(".lessonKey_included");
54+
assert.isNotNull(elem);
55+
assert.strictEqual(Key.attached(elem), key);
5256

5357
r.unmount();
5458
});
5559

5660
test("render focused", () => {
61+
const key = new LessonKey({
62+
letter: FakePhoneticModel.letter1,
63+
samples: [],
64+
timeToType: null,
65+
bestTimeToType: null,
66+
confidence: null,
67+
bestConfidence: null,
68+
}).asFocused();
69+
5770
const r = render(
5871
<FakeIntlProvider>
5972
<FakeSettingsContext>
60-
<Key
61-
lessonKey={new LessonKey({
62-
letter: FakePhoneticModel.letter1,
63-
samples: [],
64-
timeToType: null,
65-
bestTimeToType: null,
66-
confidence: null,
67-
bestConfidence: null,
68-
}).asFocused()}
69-
/>
73+
<Key lessonKey={key} />
7074
</FakeSettingsContext>
7175
</FakeIntlProvider>,
7276
);
7377

74-
assert.isNotNull(r.container.querySelector(".lessonKey_focused"));
78+
const elem = r.container.querySelector(".lessonKey_focused");
79+
assert.isNotNull(elem);
80+
assert.strictEqual(Key.attached(elem), key);
7581

7682
r.unmount();
7783
});

packages/keybr-lesson-ui/lib/Key.tsx

+12-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { type LessonKey } from "@keybr/lesson";
2-
import { type Letter } from "@keybr/phonetic-model";
32
import { type MouseProps } from "@keybr/widget";
43
import { clsx } from "clsx";
54
import { type ReactNode } from "react";
@@ -31,6 +30,7 @@ export const Key = ({
3130
return (
3231
<span
3332
{...props}
33+
ref={Key.attach(lessonKey)}
3434
key={codePoint}
3535
className={clsx(
3636
styles.lessonKey,
@@ -58,16 +58,16 @@ export const Key = ({
5858
);
5959
};
6060

61-
export function getKeyElementSelector({ codePoint }: Letter): string {
62-
return `.${styles.lessonKey}[data-code-point="${codePoint}"]`;
63-
}
61+
const attachment = Symbol();
6462

65-
export function isKeyElement(el: Element): number | null {
66-
if (el instanceof HTMLElement && el.className.includes(styles.lessonKey)) {
67-
const value = el.dataset["codePoint"] ?? null;
68-
if (value != null) {
69-
return Number(value);
63+
Key.attach = (key: LessonKey) => {
64+
return (target: Element | null): void => {
65+
if (target != null) {
66+
(target as any)[attachment] = key;
7067
}
71-
}
72-
return null;
73-
}
68+
};
69+
};
70+
71+
Key.attached = (target: Element | null): LessonKey | null => {
72+
return (target as any)?.[attachment] ?? null;
73+
};

packages/keybr-lesson-ui/lib/KeySet.test.tsx

+28-2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { LessonKey, LessonKeys } from "@keybr/lesson";
44
import { FakePhoneticModel } from "@keybr/phonetic-model";
55
import { FakeSettingsContext } from "@keybr/settings";
66
import { render } from "@testing-library/react";
7+
import { userEvent } from "@testing-library/user-event";
78
import { assert } from "chai";
89
import { KeySet } from "./KeySet.tsx";
910

10-
test("render", () => {
11+
test("render", async () => {
1112
const { letters } = FakePhoneticModel;
1213

1314
const lessonKeys = new LessonKeys([
@@ -45,10 +46,23 @@ test("render", () => {
4546
}),
4647
]);
4748

49+
const events: string[] = [];
50+
4851
const r = render(
4952
<FakeIntlProvider>
5053
<FakeSettingsContext>
51-
<KeySet lessonKeys={lessonKeys} />
54+
<KeySet
55+
lessonKeys={lessonKeys}
56+
onKeyHoverIn={(key) => {
57+
events.push(`hover in ${key.letter}`);
58+
}}
59+
onKeyHoverOut={(key) => {
60+
events.push(`hover out ${key.letter}`);
61+
}}
62+
onKeyClick={(key) => {
63+
events.push(`click ${key.letter}`);
64+
}}
65+
/>
5266
</FakeSettingsContext>
5367
</FakeIntlProvider>,
5468
);
@@ -57,5 +71,17 @@ test("render", () => {
5771
assert.isNotNull(r.queryByText("B"));
5872
assert.isNotNull(r.queryByText("C"));
5973

74+
events.length = 0;
75+
await userEvent.hover(r.getByText("A"));
76+
assert.deepStrictEqual(events, ["hover in A"]);
77+
78+
events.length = 0;
79+
await userEvent.unhover(r.getByText("A"));
80+
assert.deepStrictEqual(events, ["hover out A"]);
81+
82+
events.length = 0;
83+
await userEvent.click(r.getByText("A"));
84+
assert.deepStrictEqual(events, ["hover in A", "click A"]);
85+
6086
r.unmount();
6187
});
+42-3
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,61 @@
1-
import { type LessonKeys } from "@keybr/lesson";
1+
import { type LessonKey, type LessonKeys } from "@keybr/lesson";
22
import { type ClassName } from "@keybr/widget";
3-
import { type ReactNode } from "react";
3+
import { type ReactNode, useRef } from "react";
44
import { Key } from "./Key.tsx";
55

66
export const KeySet = ({
77
id,
88
className,
99
lessonKeys,
10+
onKeyHoverIn,
11+
onKeyHoverOut,
12+
onKeyClick,
1013
}: {
1114
readonly id?: string;
1215
readonly className?: ClassName;
1316
readonly lessonKeys: LessonKeys;
17+
readonly onKeyHoverIn?: (key: LessonKey, elem: Element) => void;
18+
readonly onKeyHoverOut?: (key: LessonKey, elem: Element) => void;
19+
readonly onKeyClick?: (key: LessonKey, elem: Element) => void;
1420
}): ReactNode => {
21+
const ref = useRef<HTMLElement>(null);
1522
return (
16-
<span id={id} className={className}>
23+
<span
24+
ref={ref}
25+
id={id}
26+
className={className}
27+
onMouseOver={(event) => {
28+
relayEvent(ref.current!, event, onKeyHoverIn);
29+
}}
30+
onMouseOut={(event) => {
31+
relayEvent(ref.current!, event, onKeyHoverOut);
32+
}}
33+
onClick={(event) => {
34+
relayEvent(ref.current!, event, onKeyClick);
35+
}}
36+
>
1737
{[...lessonKeys].map((lessonKey) => (
1838
<Key key={lessonKey.letter.codePoint} lessonKey={lessonKey} />
1939
))}
2040
</span>
2141
);
2242
};
43+
44+
function relayEvent(
45+
root: Element,
46+
{ target }: { readonly target: any },
47+
handler?: (key: LessonKey, elem: Element) => void,
48+
) {
49+
while (
50+
handler != null &&
51+
target instanceof Element &&
52+
root.contains(target)
53+
) {
54+
const key = Key.attached(target);
55+
if (key) {
56+
handler(key, target);
57+
return;
58+
}
59+
target = target.parentElement;
60+
}
61+
}

0 commit comments

Comments
 (0)