Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ Virtual keyboard library for React front-ends.

## Use

Import the `VkbReactKeyboard` component and `Key` type:
Import the `Keyboard` component and `Key` type:

```tsx
import { VkbReactKeyboard, type Key } from "v-k-b";
import { Keyboard, type Key } from "v-k-b";
Comment thread
ty2k marked this conversation as resolved.
```

In your application code, use it with a state management library like `useState`:
In your application code, keep track of the text state:

```tsx
function App() {
Expand All @@ -40,7 +40,7 @@ function App() {

return (
<>
<VkbReactKeyboard
<Keyboard
id="qwerty"
rows={[
[
Expand Down Expand Up @@ -123,7 +123,7 @@ function App() {

### How React button components work

`VkbReactKeyboard` lets you pass your own React button components to use with the keyboard. The `ButtonComponent` prop accepts things like:
`Keyboard` lets you pass your own React button components to use with the keyboard. The `ButtonComponent` prop accepts things like:

- a `Button` imported from a library like React Aria Components
- the string `"button"` to indicate you want to use the browser-native button
Expand All @@ -139,7 +139,7 @@ Use the `getButtonProps` callback that receives information about the key being

### How rows of keys work

`VkbReactKeyboard` accepts a `rows` array prop, which is an array of `Key` arrays.
`Keyboard` accepts a `rows` array prop, which is an array of `Key` arrays.

### How keys work

Expand Down Expand Up @@ -195,7 +195,15 @@ const backspaceKeyObject: KeyObject = {

### How capitalization (shift and caps lock) works

Keyboards keep track of lowercase/uppercase state internally using React's `useState()`. Keyboards initially start off in lowercase mode. You can access uppercase mode by including a Shift or Caps Lock key:
Keyboards start in lowercase mode and move between three explicit uppercase states:

- lowercase
- one-shot shift (`isShifted`)
- caps lock (`isCapsLocked`)

The `Keyboard` component manages uppercase state internally by default, and the same logic is also exported through `useKeyboard()` when you want to share uppercase state with other UI.

You can access uppercase mode by including a Shift or Caps Lock key:

```tsx
// Shift key
Expand Down Expand Up @@ -228,15 +236,61 @@ You can also use a mobile phone-style "shift or caps lock" key with a double-pre

You can specify the maximum amount of time a `shift-or-caps` button should work to enter caps lock mode by using the `shiftOrCapsDoublePressMilliseconds` prop, which defaults to 300 milliseconds if not specified.

Note that internally, "shift mode" and "caps mode" are different stateful variables. If you enter caps mode, you use a `caps` button press or `shift-or-caps` button double-press to exit it. For this reason, it might be confusing to include both options on one keyboard.
Pressing any uppercase key while uppercase is active returns the keyboard to lowercase mode, except for a rapid second `shift-or-caps` press, which promotes one-shot shift into caps lock.

If you want to show the current uppercase state elsewhere in your UI, share a controller created with `useKeyboard()`:

```tsx
import { Keyboard, useKeyboard, type Key } from "v-k-b";
import { useState } from "react";

function App() {
const [text, setText] = useState("");
const keyboard = useKeyboard({
handlePress: (key: Key) => {
if (typeof key === "string") return setText((prev) => prev + key);
return setText((prev) => prev + (key.v ?? key.k));
},
});

return (
<>
<div aria-live="polite">
{keyboard.isCapsLocked
? "Caps Lock On"
: keyboard.isShifted
? "Shift On"
: "Lowercase"}
</div>

<Keyboard
id="shared-state"
rows={[
[
{ k: "⇧ Shift/Caps", uK: "⇧ Shift/Caps", special: "shift-or-caps" },
{ k: "Caps Lock", uK: "Caps Lock", special: "caps" },
"a",
"b",
],
]}
ButtonComponent="button"
buttonActionProp="onClick"
keyboardController={keyboard}
/>

<output>{text}</output>
</>
);
}
```

If you use a `Key` that's just a string, the Keyboard will infer how capitalization should work by using [`String.prototype.toLocaleUppercase()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLocaleUpperCase).

If a `KeyObject` with a callback `cb` is pressed while in shift mode, the keyboard returns to lowercase mode.

### How styling works

Any extra props passed to `VkbReactKeyboard` are spread on the parent `<div>` being returned. Use this to target your styles by passing `className`, or use a CSS-in-JS library to style the component.
Any extra props passed to `Keyboard` are spread on the parent `<div>` being returned. Use this to target your styles by passing `className`, or use a CSS-in-JS library to style the component.

### Accessibility

Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vkb React example</title>
<title>v-k-b React example</title>
</head>
<body>
<div id="root"></div>
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
{
"name": "v-k-b",
"version": "0.0.2",
"version": "0.0.3",
"description": "Virtual keyboard for React front-ends.",
"keywords": [
"keyboard",
"react",
"design system"
],
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down Expand Up @@ -60,5 +66,17 @@
},
"allowScripts": {
"fsevents@2.3.3": true
}
},
"license": "Apache-2.0",
"author": "Tyler Krys <tylerkrys@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/ty2k/vkb.git",
"directory": "."
},
"bugs": {
"url": "https://github.com/ty2k/vkb/issues"
},
"homepage": "https://github.com/ty2k/vkb#readme",
"private": false
}
114 changes: 112 additions & 2 deletions src/__tests__/Keyboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";

import { Keyboard } from "../lib/Keyboard";
import { useKeyboard } from "../lib/useKeyboard";

afterEach(() => {
cleanup();
Expand Down Expand Up @@ -62,6 +64,44 @@ describe("Keyboard", () => {
expect(handlePress).toHaveBeenNthCalledWith(4, "c");
});

it("exits uppercase when a different uppercase key is pressed", () => {
const handlePress = vi.fn();

render(
<Keyboard
id="qwerty-uppercase-exit"
rows={[
[
{ k: "⇧ Shift", uK: "⇧ Shift", special: "shift" },
{ k: "Caps Lock", uK: "Caps Lock", special: "caps" },
{ k: "⇪", uK: "⇪", special: "shift-or-caps" },
"a",
],
]}
ButtonComponent="button"
buttonActionProp="onClick"
handlePress={handlePress}
/>
);

fireEvent.click(screen.getByRole("button", { name: "⇧ Shift" }));
fireEvent.click(screen.getByRole("button", { name: "Caps Lock" }));
fireEvent.click(screen.getByRole("button", { name: "a" }));

fireEvent.click(screen.getByRole("button", { name: "Caps Lock" }));
fireEvent.click(screen.getByRole("button", { name: "⇧ Shift" }));
fireEvent.click(screen.getByRole("button", { name: "a" }));

fireEvent.click(screen.getByRole("button", { name: "⇪" }));
fireEvent.click(screen.getByRole("button", { name: "⇧ Shift" }));
fireEvent.click(screen.getByRole("button", { name: "a" }));

expect(handlePress).toHaveBeenCalledTimes(3);
expect(handlePress).toHaveBeenNthCalledWith(1, "a");
expect(handlePress).toHaveBeenNthCalledWith(2, "a");
expect(handlePress).toHaveBeenNthCalledWith(3, "a");
});

it("emits shifted symbol values for KeyObject keys", () => {
const handlePress = vi.fn();

Expand Down Expand Up @@ -165,7 +205,6 @@ describe("Keyboard", () => {
fireEvent.click(screen.getByRole("button", { name: "A" }));
fireEvent.click(screen.getByRole("button", { name: "B" }));

fireEvent.click(screen.getByRole("button", { name: "⇧ Shift" }));
fireEvent.click(screen.getByRole("button", { name: "⇧ Shift" }));
fireEvent.click(screen.getByRole("button", { name: "b" }));

Expand All @@ -175,6 +214,77 @@ describe("Keyboard", () => {
expect(handlePress).toHaveBeenNthCalledWith(3, "b");
});

it("exposes uppercase state through a shared keyboard controller", () => {
function Harness(): ReactNode {
const keyboardController = useKeyboard();

return (
<>
<output data-testid="state">
{JSON.stringify({
isUppercase: keyboardController.isUppercase,
isShifted: keyboardController.isShifted,
isCapsLocked: keyboardController.isCapsLocked,
})}
</output>
<Keyboard
id="qwerty-controller"
rows={[
[
{ k: "⇧ Shift", uK: "⇧ Shift", special: "shift-or-caps" },
{ k: "Caps Lock", uK: "Caps Lock", special: "caps" },
"a",
],
]}
ButtonComponent="button"
buttonActionProp="onClick"
keyboardController={keyboardController}
/>
</>
);
}

render(<Harness />);

expect(screen.getByTestId("state").textContent).toBe(
JSON.stringify({
isUppercase: false,
isShifted: false,
isCapsLocked: false,
})
);

fireEvent.click(screen.getByRole("button", { name: "⇧ Shift" }));

expect(screen.getByTestId("state").textContent).toBe(
JSON.stringify({
isUppercase: true,
isShifted: true,
isCapsLocked: false,
})
);

fireEvent.click(screen.getByRole("button", { name: "⇧ Shift" }));

expect(screen.getByTestId("state").textContent).toBe(
JSON.stringify({
isUppercase: true,
isShifted: false,
isCapsLocked: true,
})
);

fireEvent.click(screen.getByRole("button", { name: "Caps Lock" }));

expect(screen.getByTestId("state").textContent).toBe(
JSON.stringify({
isUppercase: false,
isShifted: false,
isCapsLocked: false,
})
);
});

it("spreads additional div props onto the outer container", () => {
render(
<Keyboard
Expand All @@ -192,6 +302,6 @@ describe("Keyboard", () => {

expect(keyboard.className).toContain("custom-keyboard");
expect(keyboard.getAttribute("data-layout")).toBe("compact");
expect(keyboard.getAttribute("id")).toBe("vkb-qwerty-div-props");
expect(keyboard.getAttribute("id")).toBe("qwerty-div-props");
});
});
4 changes: 3 additions & 1 deletion src/__tests__/KeyboardKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ describe("KeyboardKey", () => {
const element = KeyboardKey({
k: { k: "`", uK: "~" },
onActivate,
isShiftMode: true,
isUppercase: true,
isShifted: true,
isCapsLocked: false,
ButtonComponent: "button",
buttonActionProp: "onClick",
});
Expand Down
Loading