diff --git a/README.md b/README.md index 81f9762..2f2fe33 100644 --- a/README.md +++ b/README.md @@ -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"; ``` -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() { @@ -40,7 +40,7 @@ function App() { return ( <> - { + if (typeof key === "string") return setText((prev) => prev + key); + return setText((prev) => prev + (key.v ?? key.k)); + }, + }); + + return ( + <> +
+ {keyboard.isCapsLocked + ? "Caps Lock On" + : keyboard.isShifted + ? "Shift On" + : "Lowercase"} +
+ + + + {text} + + ); +} +``` 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). @@ -236,7 +290,7 @@ If a `KeyObject` with a callback `cb` is pressed while in shift mode, the keyboa ### How styling works -Any extra props passed to `VkbReactKeyboard` are spread on the parent `
` 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 `
` being returned. Use this to target your styles by passing `className`, or use a CSS-in-JS library to style the component. ### Accessibility diff --git a/index.html b/index.html index e33b4bb..d80de9d 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - vkb React example + v-k-b React example
diff --git a/package-lock.json b/package-lock.json index 61e7b2e..933bca2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "vkb-react", - "version": "0.0.2", + "name": "v-k-b", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vkb-react", - "version": "0.0.2", + "name": "v-k-b", + "version": "0.0.3", + "license": "Apache-2.0", "devDependencies": { "@babel/core": "7.29.7", "@eslint/js": "10.0.1", diff --git a/package.json b/package.json index 9e49d22..713d3c4 100644 --- a/package.json +++ b/package.json @@ -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", @@ -60,5 +66,17 @@ }, "allowScripts": { "fsevents@2.3.3": true - } + }, + "license": "Apache-2.0", + "author": "Tyler Krys ", + "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 } diff --git a/src/__tests__/Keyboard.test.tsx b/src/__tests__/Keyboard.test.tsx index dbfc68f..912d0f9 100644 --- a/src/__tests__/Keyboard.test.tsx +++ b/src/__tests__/Keyboard.test.tsx @@ -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(); @@ -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( + + ); + + 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(); @@ -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" })); @@ -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 ( + <> + + {JSON.stringify({ + isUppercase: keyboardController.isUppercase, + isShifted: keyboardController.isShifted, + isCapsLocked: keyboardController.isCapsLocked, + })} + + + + ); + } + + render(); + + 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( { 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"); }); }); diff --git a/src/__tests__/KeyboardKey.test.ts b/src/__tests__/KeyboardKey.test.ts index 7abdf19..985a97d 100644 --- a/src/__tests__/KeyboardKey.test.ts +++ b/src/__tests__/KeyboardKey.test.ts @@ -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", }); diff --git a/src/example/App.tsx b/src/example/App.tsx index 31e0ed6..87ce543 100644 --- a/src/example/App.tsx +++ b/src/example/App.tsx @@ -2,33 +2,51 @@ import { useState } from "react"; import { Keyboard } from "../lib/Keyboard"; import type { Key } from "../lib/types"; +import { useKeyboard } from "../lib/useKeyboard"; -// import { Keyboard, type Key } from "../../dist"; +// Un-comment the `import` line below to test +// distributed code after running `npm run build`: +// ----------------------------------------------- +// import { Keyboard, useKeyboard, type Key } from "../../dist"; import "./App.css"; function App() { const [text, setText] = useState(""); - function handleKeyPress(key: Key) { - if (typeof key === "string") return setText(text + key); - if (key.v) return setText(text + key.v); - return setText(text + key.k); - } - function handleBackspace() { - setText(text.substring(0, text.length - 1)); + setText((prev) => prev.substring(0, prev.length - 1)); } function handleClear() { setText(""); } + const keyboard = useKeyboard({ + handlePress: (key: Key) => { + if (typeof key === "string") { + setText((prev) => prev + key); + return; + } + + setText((prev) => prev + (key.v ?? key.k)); + }, + shiftOrCapsDoublePressMilliseconds: 200, + }); + return ( <>

- Input: {text} + Input: {text} +

+

+ Keyboard mode:{" "} + {keyboard.isCapsLocked + ? "Caps Lock On" + : keyboard.isShifted + ? "Shift On" + : "Lowercase"}

handleKeyPress(key)} - shiftOrCapsDoublePressMilliseconds={200} + keyboardController={keyboard} /> ); diff --git a/src/lib/Keyboard.tsx b/src/lib/Keyboard.tsx index d7a0bf1..f6cb5d8 100644 --- a/src/lib/Keyboard.tsx +++ b/src/lib/Keyboard.tsx @@ -1,9 +1,4 @@ -import { - useRef, - useState, - type ComponentPropsWithoutRef, - type ElementType, -} from "react"; +import { type ComponentPropsWithoutRef, type ElementType } from "react"; import type { Key, @@ -13,6 +8,7 @@ import type { } from "./types"; import { KeyboardKey } from "./KeyboardKey"; +import { useKeyboard, type KeyboardController } from "./useKeyboard"; export interface KeyboardProps< C extends ElementType, @@ -43,6 +39,13 @@ export interface KeyboardProps< getButtonProps?: ( context: ButtonRenderContext ) => Partial>; + /** Optional shared controller for keyboard uppercase + press handling. + * + * When provided, the `Keyboard` will use the controller’s handlers (including + * its configured `handlePress`) instead of this component’s `handlePress` + * prop. + */ + keyboardController?: KeyboardController; /** * Number of milliseconds to allow a shift/caps lock double press for * `KeyObject` keys with `special: "shift-or-caps"` to enable caps lock mode. @@ -70,58 +73,15 @@ export function Keyboard< buttonActionProp, buttonProps, getButtonProps, + keyboardController, shiftOrCapsDoublePressMilliseconds = 300, ...divProps }: KeyboardProps) { - const [isShiftMode, setIsShiftMode] = useState(false); - const [isCapsMode, setIsCapsMode] = useState(false); - const lastShiftOrCapsPressAtRef = useRef(null); - - const isUppercaseMode = isCapsMode || isShiftMode; - - const handleKeyPress = (key: Key) => { - // Fire the `handlePress()` function passed in by the consumer first. - handlePress(key); - - // If a `KeyObject` with `special` set to "shift", "caps", or - // "shift-or-caps") is pressed, exit both shift and caps modes. - // Any other key input isn't affected because `handlePress()` has already - // been dispatched. - if ( - typeof key === "object" && - (key.special === "shift" || - key.special === "caps" || - key.special === "shift-or-caps") - ) { - setIsShiftMode(false); - setIsCapsMode(false); - - // If shift mode is active and a regular key is pressed, exit shift mode - // so only that single character is uppercase. - } else if (isShiftMode) { - setIsShiftMode(false); - } - }; - - // Handle `KeyObject` presses with `special: "shift-or-caps"`, - // which should behave similarly to a mobile phone keyboard shift. - const handleShiftOrCapsPress = () => { - const now = Date.now(); - const lastPressAt = lastShiftOrCapsPressAtRef.current; - - if ( - lastPressAt !== null && - now - lastPressAt <= shiftOrCapsDoublePressMilliseconds - ) { - setIsCapsMode((prev) => !prev); - setIsShiftMode(false); - lastShiftOrCapsPressAtRef.current = null; - return; - } - - lastShiftOrCapsPressAtRef.current = now; - setIsShiftMode((prev) => !prev); - }; + const internalKeyboardController = useKeyboard({ + handlePress, + shiftOrCapsDoublePressMilliseconds, + }); + const controller = keyboardController ?? internalKeyboardController; const ariaAttributes: Record = { role: ariaRole, @@ -134,7 +94,7 @@ export function Keyboard< } return ( -
+
{rows.map((row, rowIndex) => { return ( @@ -148,8 +108,10 @@ export function Keyboard< setIsShiftMode((prev) => !prev)} - isShiftMode={isUppercaseMode} + onActivate={controller.handleShiftPress} + isUppercase={controller.isUppercase} + isShifted={controller.isShifted} + isCapsLocked={controller.isCapsLocked} ButtonComponent={ButtonComponent} buttonActionProp={buttonActionProp} buttonProps={buttonProps} @@ -164,11 +126,10 @@ export function Keyboard< { - setIsCapsMode((prev) => !prev); - setIsShiftMode(false); - }} - isShiftMode={isUppercaseMode} + onActivate={controller.handleCapsPress} + isUppercase={controller.isUppercase} + isShifted={controller.isShifted} + isCapsLocked={controller.isCapsLocked} ButtonComponent={ButtonComponent} buttonActionProp={buttonActionProp} buttonProps={buttonProps} @@ -186,8 +147,10 @@ export function Keyboard< { key.cb?.(pressedKey); - - if (isShiftMode) { - setIsShiftMode(false); - } + controller.handleCallbackKeyPress(); }, } : key; @@ -231,8 +193,10 @@ export function Keyboard< void; - /** Is the keyboard in shift mode */ - isShiftMode: boolean; + /** Is the keyboard in any uppercase mode */ + isUppercase: boolean; + /** Is the keyboard in one-shot shift mode */ + isShifted: boolean; + /** Is the keyboard in caps lock mode */ + isCapsLocked: boolean; /** Button component used to render each key */ ButtonComponent: C; /** The action prop name to use on the button, @@ -38,7 +42,9 @@ export function KeyboardKey< >({ k, onActivate, - isShiftMode, + isUppercase, + isShifted, + isCapsLocked, ButtonComponent, buttonActionProp, buttonProps, @@ -48,13 +54,15 @@ export function KeyboardKey< const isShiftKey = typeof k === "object" && (k?.special === "shift" || k?.special === "shift-or-caps"); - const label = getKeyLabel(k, isShiftMode); - const value = getKeyValue(k, isShiftMode); + const label = getKeyLabel(k, isUppercase); + const value = getKeyValue(k, isUppercase); const context: ButtonRenderContext = { keyDef: k, isShiftKey, - isShiftMode, + isUppercase, + isShifted, + isCapsLocked, label, value, }; diff --git a/src/lib/getKeyLabel.ts b/src/lib/getKeyLabel.ts index cf308a7..734066a 100644 --- a/src/lib/getKeyLabel.ts +++ b/src/lib/getKeyLabel.ts @@ -2,23 +2,23 @@ import type { Key } from "./types"; /** * Return a visual label for the key button given a {@link Key} and - * shift state. + * uppercase state. * * @param {Key} key The `KeyString` or `KeyObject` - * @param {boolean} isShiftMode Whether or not the keyboard is in shift mode + * @param {boolean} isUppercase Whether or not the keyboard is in uppercase mode * @returns {string} The label for the `KeyString` or `KeyObject` */ -export function getKeyLabel(key: Key, isShiftMode: boolean): string { +export function getKeyLabel(key: Key, isUppercase: boolean): string { // Handle `KeyString` input for `Key`. if (typeof key === "string") { - // Shift mode sets string to uppercase. - return isShiftMode ? key.toLocaleUpperCase() : key; + // Uppercase mode sets string to uppercase. + return isUppercase ? key.toLocaleUpperCase() : key; } // All remaining cases below handle `KeyObject` input for `Key`. - // Handle shift mode. - if (isShiftMode) { + // Handle uppercase mode. + if (isUppercase) { // If the uppercase text display is supplied, use that. if (key.uK) return key.uK; diff --git a/src/lib/getKeyValue.ts b/src/lib/getKeyValue.ts index 7e49781..2c7b6ef 100644 --- a/src/lib/getKeyValue.ts +++ b/src/lib/getKeyValue.ts @@ -2,22 +2,22 @@ import type { Key } from "./types"; /** * Return a string value for the key button given a {@link Key} and - * shift state. + * uppercase state. * * @param {Key} key key The `KeyString` or `KeyObject` - * @param {boolean} isShiftMode Whether or not the keyboard is in shift mode + * @param {boolean} isUppercase Whether or not the keyboard is in uppercase mode * @returns {string} The value for the `KeyString` or `KeyObject` */ -export function getKeyValue(key: Key, isShiftMode: boolean): string { +export function getKeyValue(key: Key, isUppercase: boolean): string { // Handle `KeyString` input for `Key`. if (typeof key === "string") { - return isShiftMode ? key.toLocaleUpperCase() : key; + return isUppercase ? key.toLocaleUpperCase() : key; } // All remaining cases below handle `KeyObject` input for `Key`. - // Handle shift mode. - if (isShiftMode) { + // Handle uppercase mode. + if (isUppercase) { // If the uppercase value is supplied, use that. if (key.uV) return key.uV; diff --git a/src/lib/index.ts b/src/lib/index.ts index a904c81..7993a67 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,2 +1,8 @@ export { Keyboard } from "./Keyboard"; +export { useKeyboard } from "./useKeyboard"; export type { Key } from "./types"; +export type { + KeyboardController, + UppercaseMode, + UseKeyboardOptions, +} from "./useKeyboard"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 92078f5..6ae97eb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -71,8 +71,12 @@ export interface ButtonRenderContext { keyDef: Key; /** Is the key being returned the shift key. */ isShiftKey: boolean; - /** Is the keyboard in shift mode. */ - isShiftMode: boolean; + /** True when the keyboard is in any uppercase mode. */ + isUppercase: boolean; + /** True when the keyboard is in one-shot shift mode. */ + isShifted: boolean; + /** True when the keyboard is in caps lock mode. */ + isCapsLocked: boolean; label: string; value: string; } diff --git a/src/lib/useKeyboard.ts b/src/lib/useKeyboard.ts new file mode 100644 index 0000000..0de3288 --- /dev/null +++ b/src/lib/useKeyboard.ts @@ -0,0 +1,138 @@ +import { useRef, useState } from "react"; + +import type { Key } from "./types"; + +export type UppercaseMode = "lowercase" | "shifted" | "caps-locked"; + +export interface UseKeyboardOptions { + /** Default function that fires when a key is pressed */ + handlePress?: (key: Key) => void; + /** + * Number of milliseconds to allow a shift/caps lock double press for + * `KeyObject` keys with `special: "shift-or-caps"` to enable caps lock mode. + */ + shiftOrCapsDoublePressMilliseconds?: number; +} + +export interface KeyboardController { + /** + * The current uppercase mode for the `Keyboard`: + * + * - `lowercase` is the default mode. + * - `shifted` is a one-shot shift/uppercase like on a mobile phone with a + * single shift key press. + * - `caps-locked` is uppercase mode until it is explicitly exited. + */ + uppercaseMode: UppercaseMode; + /** True for either one-shot shift (`shifted`) or caps lock (`caps-locked`) */ + isUppercase: boolean; + /** True only for one-shot shift (`shifted`) */ + isShifted: boolean; + /** True only for caps lock (`caps-locked`) */ + isCapsLocked: boolean; + /** Handles plain and non-callback `KeyObject` activations */ + handleKeyPress: (key: Key) => void; + /** Handles callback key activations */ + handleCallbackKeyPress: () => void; + /** Handles `special: "shift"` activations */ + handleShiftPress: () => void; + /** Handles `special: "caps"` activations */ + handleCapsPress: () => void; + /** Handles `special: "shift-or-caps"` activations */ + handleShiftOrCapsPress: () => void; +} + +export function useKeyboard({ + handlePress = () => {}, + shiftOrCapsDoublePressMilliseconds = 300, +}: UseKeyboardOptions = {}): KeyboardController { + // Keyboard starts in lowercase mode + const [uppercaseMode, setUppercaseMode] = + useState("lowercase"); + // Time in Unix milliseconds of last shift or caps button press + const lastShiftOrCapsPressAtRef = useRef(null); + + const isUppercase = uppercaseMode !== "lowercase"; + const isShifted = uppercaseMode === "shifted"; + const isCapsLocked = uppercaseMode === "caps-locked"; + + const resetShiftOrCapsWindow = () => { + lastShiftOrCapsPressAtRef.current = null; + }; + + const setLowercaseMode = () => { + setUppercaseMode("lowercase"); + resetShiftOrCapsWindow(); + }; + + const consumeOneShotShift = () => { + resetShiftOrCapsWindow(); + + if (uppercaseMode === "shifted") { + setUppercaseMode("lowercase"); + } + }; + + const handleKeyPress = (key: Key) => { + handlePress(key); + consumeOneShotShift(); + }; + + const handleCallbackKeyPress = () => { + consumeOneShotShift(); + }; + + const handleShiftPress = () => { + if (isUppercase) { + setLowercaseMode(); + return; + } + + setUppercaseMode("shifted"); + resetShiftOrCapsWindow(); + }; + + const handleCapsPress = () => { + if (isUppercase) { + setLowercaseMode(); + return; + } + + setUppercaseMode("caps-locked"); + resetShiftOrCapsWindow(); + }; + + const handleShiftOrCapsPress = () => { + const now = Date.now(); + const lastPressAt = lastShiftOrCapsPressAtRef.current; + const isDoublePress = + lastPressAt !== null && + now - lastPressAt <= shiftOrCapsDoublePressMilliseconds; + + if (isDoublePress) { + setUppercaseMode("caps-locked"); + resetShiftOrCapsWindow(); + return; + } + + if (isUppercase) { + setLowercaseMode(); + return; + } + + lastShiftOrCapsPressAtRef.current = now; + setUppercaseMode("shifted"); + }; + + return { + uppercaseMode, + isUppercase, + isShifted, + isCapsLocked, + handleKeyPress, + handleCallbackKeyPress, + handleShiftPress, + handleCapsPress, + handleShiftOrCapsPress, + }; +}