{language} keyboard
+Click in the field, then press keys on the on-screen keyboard.
+diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 0f77587..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "permissions": { - "allow": ["Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)"], - "deny": ["Read(./.env)", "Read(./.env.*)", "Read(**/*.pem)", "Read(**/*.key)"] - } -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a2df57f..30efd6b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -2,6 +2,22 @@ name: Bug report description: Report something that is not working as expected labels: [bug] body: + - type: dropdown + id: package + attributes: + label: Package + description: Which package shows the problem? + options: + - saccade-keyboards (the component and its options) + - saccade-en + - saccade-es + - saccade-fr + - saccade-hi + - saccade-te + - saccade-ur + - Not sure + validations: + required: true - type: textarea id: what-happened attributes: @@ -13,18 +29,29 @@ body: id: steps attributes: label: Steps to reproduce + description: Include the language, arrangement and region you used, and any options such as `numpad`, `currencypad`, `theme`, `font` or `fontSize`. placeholder: | 1. ... 2. ... validations: required: true + - type: input + id: width + attributes: + label: Keyboard width + description: How wide the keyboard was when it happened, for example 480 px. Keys look different below 800 px. - type: input id: version attributes: - label: Version - description: Release or commit you are using. + label: Package version + description: The version of the package, for example 0.1.0. + - type: input + id: environment + attributes: + label: React version, browser and device + description: For example React 19.1, Chrome 130 on Windows, or Safari on iPhone. - type: textarea id: logs attributes: label: Logs or screenshots - description: Remove any secrets or personal information first. + description: A screenshot of the keyboard helps most. Remove any secrets or personal information first. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..35501e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/coldsofttech/SaccadeKeyboards/security/advisories/new + about: Please report vulnerabilities privately, not in a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 247c561..9226c70 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -2,6 +2,22 @@ name: Feature request description: Suggest an idea or improvement labels: [enhancement] body: + - type: dropdown + id: package + attributes: + label: Package + description: Which package is this about? + options: + - saccade-keyboards (the component and its options) + - saccade-en + - saccade-es + - saccade-fr + - saccade-hi + - saccade-te + - saccade-ur + - Not sure + validations: + required: true - type: textarea id: problem attributes: diff --git a/.github/ISSUE_TEMPLATE/layout_fix.yml b/.github/ISSUE_TEMPLATE/layout_fix.yml new file mode 100644 index 0000000..23dac6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/layout_fix.yml @@ -0,0 +1,37 @@ +name: Layout fix +description: A key is wrong, missing or in the wrong place on an existing keyboard +title: 'Layout fix: ' +labels: [bug, layout] +body: + - type: dropdown + id: package + attributes: + label: Language + options: + - English (saccade-en) + - Spanish (saccade-es) + - French (saccade-fr) + - Hindi (saccade-hi) + - Telugu (saccade-te) + - Urdu (saccade-ur) + validations: + required: true + - type: input + id: variant + attributes: + label: Arrangement and region + description: For example "French, FR, native" or "Hindi, InScript, devanagari digits". + validations: + required: true + - type: textarea + id: key + attributes: + label: Which key is wrong? + description: Say which key, which layer it is on (normal, Shift, AltGr or symbols), what it types now and what it should type. + validations: + required: true + - type: textarea + id: reference + attributes: + label: Reference + description: A link to the official layout, or a photo of a real keyboard, that shows the correct key. diff --git a/.github/ISSUE_TEMPLATE/new_language.yml b/.github/ISSUE_TEMPLATE/new_language.yml new file mode 100644 index 0000000..74fa64f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_language.yml @@ -0,0 +1,56 @@ +name: New language +description: Ask for a keyboard for a language that is not here yet +title: 'New language: ' +labels: [enhancement, language] +body: + - type: markdown + attributes: + value: | + Keyboards are available for English, Spanish, French, Hindi, Telugu and Urdu. Each language is its own package, so a new one does not make the others bigger. + - type: input + id: language + attributes: + label: Language + description: The name and the language code, for example Tamil (ta). + validations: + required: true + - type: input + id: script + attributes: + label: Script + description: For example Latin, Devanagari, Arabic or Cyrillic. Say if it is written right to left. + validations: + required: true + - type: textarea + id: standard + attributes: + label: Keyboard layout to follow + description: The layout people already use, such as a national standard, InScript, or the one on their phone or computer. A link to its reference chart helps most. + validations: + required: true + - type: textarea + id: arrangements + attributes: + label: Other arrangements + description: For example a phonetic layout that maps Latin keys to the letters of the language, or an alphabetical order. + - type: textarea + id: regions + attributes: + label: Regions or digits + description: Countries whose keyboards differ, or digit styles (such as Latin and native digits) that people need. + - type: textarea + id: special + attributes: + label: Special characters + description: Letters, signs that sit on a letter, ligatures or punctuation that are easy to get wrong. + - type: dropdown + id: help + attributes: + label: Can you help review it? + description: A native speaker checking the keys is the best way to get a layout right. + options: + - Yes, I can check the layout + - Yes, and I can write the layout + - No, I only need it + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/new_region.yml b/.github/ISSUE_TEMPLATE/new_region.yml new file mode 100644 index 0000000..917b453 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_region.yml @@ -0,0 +1,50 @@ +name: Region-specific layout +description: Ask for a regional variant of a keyboard that already exists +title: 'Region: ' +labels: [enhancement, region] +body: + - type: dropdown + id: language + attributes: + label: Language + options: + - English + - Spanish + - French + - Hindi + - Telugu + - Urdu + - Another language + validations: + required: true + - type: input + id: region + attributes: + label: Region + description: The country or area, for example Ireland for English or Germany for French. + validations: + required: true + - type: textarea + id: difference + attributes: + label: How does the keyboard differ? + description: Which keys sit in other places or type other characters compared with the closest layout we have, for example AZERTY instead of QWERTY, or a different @ or currency key. + validations: + required: true + - type: textarea + id: reference + attributes: + label: Reference + description: A link to the official layout or a picture of a real keyboard from that region. + validations: + required: true + - type: dropdown + id: help + attributes: + label: Can you help review it? + options: + - Yes, I can check the layout + - Yes, and I can write the layout + - No, I only need it + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3472f05..fa34b61 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,10 @@ +## Packages affected + + + ## Related issues @@ -10,5 +14,6 @@ - [ ] The change is focused on one thing - [ ] `CHANGELOG.md` has a short `[Unreleased]` entry -- [ ] README or docs are updated if behavior changed +- [ ] Tests are added or updated, including an `axe` check for new components and `validateLayout` for a new or changed layout +- [ ] The package README is updated if exports or props changed - [ ] No secrets or personal data are included diff --git a/.github/social-preview.html b/.github/social-preview.html new file mode 100644 index 0000000..84b680b --- /dev/null +++ b/.github/social-preview.html @@ -0,0 +1,82 @@ + +
On-screen keyboards for React
+`, copying the files of an existing language package. The package is named `@coldsofttech/saccade-` and starts at version `0.1.0`.
+2. Write the layouts in `src/index.js`. Export `layout` (the default) and `createLayout(arrangement, region)`, and list any font the script needs in `fonts`. `saccade-keyboards` is a peer dependency, imported from `@coldsofttech/saccade-keyboards/core`.
+3. Add `src/index.test.js`. It runs `validateLayout` on every arrangement and region, and checks the keys that are easy to get wrong.
+4. Add a `README.md` for the package: what it provides, its arrangements, regions and digits, and how to register it.
+5. Add the package to `.size-limit.json`, to the demo in `examples/demo/src/main.jsx` (its layout, regions and arrangements) and to `examples/demo/package.json`.
+6. Add it to the language table in the root `README.md`, to the bug report, feature request and layout fix forms in `.github/ISSUE_TEMPLATE`, and to the package list in [architecture](architecture.md).
+7. Add an `[Unreleased]` entry in `CHANGELOG.md`.
+8. Run `npm run build`, then `npm run check:packages`, then `npx vitest run packages/saccade-`.
+
+After the first release, register the package as a trusted publisher on npm (see [repository setup](repo-setup.md)).
+
+## A new arrangement or region
+
+1. Add it to the language's `createLayout`, reusing the shared helpers instead of repeating keys that do not change.
+2. Add the region or arrangement to the test, so it is checked by `validateLayout`.
+3. Add it to the demo, the language's README and the root README table.
+4. Add an `[Unreleased]` entry in `CHANGELOG.md`.
+
+## Checking your work
+
+- Open the demo (`npm run build`, then `npm run dev`) and press every key on every layer.
+- Look at the keyboard at 430 px and at 801 px or wider. At 800 px or less the Shift and AltGr characters are hidden until those keys are pressed.
+- Check with the number pad and the currency pad on, and in the dark theme.
+- Check a script that has signs sitting on a letter: they should show a dotted circle.
diff --git a/docs/architecture.md b/docs/architecture.md
index a1e0c53..24cd54e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,6 +1,6 @@
# Architecture
-How the pieces of this template fit together. For day-to-day commands, see [development](development.md). For one-time repository settings, see [repository setup](repo-setup.md).
+How the pieces of Saccade Keyboards fit together. For day-to-day commands, see [development](development.md). For one-time repository settings, see [repository setup](repo-setup.md).
## Overview
@@ -30,7 +30,7 @@ flowchart LR
```text
packages// one publishable package: src/, tests next to source, own vite.config.js
-examples/demo/ small Vite app that consumes the packages
+examples/demo/ the demo app, which uses every package
scripts/ repository helper scripts (see scripts/README.md)
vitest.config.js runs every package as a Vitest project, sets coverage thresholds
eslint.config.js shared lint rules
@@ -49,13 +49,12 @@ Each package owns its version in its `package.json`, builds on its own with Vite
| `audit.yml` | Weekly, manual, and pull requests that change `package-lock.json` | Runs `npm audit` on production dependencies to catch newly published vulnerabilities. |
| `security.yml` | Pull request, push to `main`, weekly, manual | Runs Semgrep and uploads SARIF to the Security tab. Not part of the required `ci` check. |
| `release.yml` | GitHub release published | Checks the tag matches the package version, builds, then publishes that package to npm. |
-| `init.yml` | Manual | One-time placeholder replacement in a repo created from the template. Skipped in the template itself. |
CodeQL runs through GitHub's default setup rather than a workflow file.
## Branch governance
-The `main` ruleset in `.github/rulesets/main.json` is applied by `scripts/apply-repo-settings.js`. It requires:
+The `main` ruleset in `.github/rulesets/main.json` is applied by hand (see [repository setup](repo-setup.md)). It requires:
- a pull request with a code owner review and resolved conversations, squash merge only
- the `ci` and `dependency-review` checks, with the branch up to date
@@ -67,9 +66,35 @@ Repository admins can bypass through a pull request so a solo maintainer is not
## Release flow
1. Bump `version` in the package's `package.json` in a pull request.
-2. After merge, publish a GitHub release tagged `@`, for example `@scope/example-react-package@1.0.0`.
+2. After merge, publish a GitHub release tagged `@`, for example `@coldsofttech/saccade-keyboards@1.0.0`.
3. `release.yml` builds and packs that package in one job, then a second job in the `npm` environment publishes it using OpenID Connect. No token is stored, and only the publish job gets the `id-token` permission.
-## Template lifecycle
+## Packages
-GitHub copies files, not settings. A new repo therefore needs three steps: run **Init** to replace the template owner and name, rename the example package and set your npm scope, and run `npm run repo:settings` to apply protections and the ruleset.
+| Package | What it is |
+| --------------------------------- | --------------------------------------------------------------------------- |
+| `@coldsofttech/saccade-keyboards` | The `Keyboard` component, the registry and the layout helpers shared by all |
+| `@coldsofttech/saccade-en` | English: US, GB, AU, CA and IN, QWERTY or alphabetical |
+| `@coldsofttech/saccade-es` | Spanish: ES, MX, AR, CO, US and Latin America |
+| `@coldsofttech/saccade-fr` | French: FR, BE, CA and CH |
+| `@coldsofttech/saccade-hi` | Hindi: InScript or phonetic, with Latin or Devanagari digits |
+| `@coldsofttech/saccade-te` | Telugu: InScript or phonetic, with Latin or Telugu digits |
+| `@coldsofttech/saccade-ur` | Urdu: standard or phonetic, with Latin or Urdu digits |
+
+## Keyboard package
+
+`packages/saccade-keyboards` has two entry points:
+
+| Entry | What it is |
+| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `@coldsofttech/saccade-keyboards` | `Keyboard` picked by `language`, with a registry that loads each layout when first needed. |
+| `@coldsofttech/saccade-keyboards/core` | The engine: `Keyboard` for a layout object, `useKeyboard`, `applyKey`, `validateLayout` and the layout helpers. |
+| `@coldsofttech/saccade-keyboards/style.css` | The styles, imported once by the app. |
+
+Each language is its own package, `@coldsofttech/saccade-` (for example `saccade-en`), holding its layouts and depending on `saccade-keyboards/core` as a peer. The layout format is described in [layout format](layout-format.md), and the steps to add a language are in [adding a language](adding-a-language.md).
+
+### Sizing
+
+The keyboard fills the width of its parent and needs at least 430 px, or 600 px with the number pad. At 800 px or less, a CSS container query hides the second and third characters of a key (the Shift symbol and the AltGr corner) until Shift or AltGr is pressed, because the keys are too small to show them without overlapping.
+
+`Keyboard` never edits a field. It reports key presses through `onKey`, and the app applies them, with `applyKey` for a plain input or textarea. Keys sit left to right for every language, because they follow the physical keys, so the keyboard is always `dir="ltr"`.
diff --git a/docs/development.md b/docs/development.md
index 917442a..2d2974f 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -3,49 +3,53 @@
## Setup
- Node 22 or newer. `.nvmrc` pins 24, the version used for linting, building and releasing.
-- `npm ci` installs every workspace (`packages/*`, `examples/*`).
+- `npm ci` installs every workspace (`packages/*` and `examples/*`).
- `pip install pre-commit && pre-commit install --hook-type pre-commit --hook-type post-commit`.
## Scripts
All scripts run from the repository root.
-| Script | Purpose |
-| --------------------------------- | ---------------------------------------------------------------- |
-| `npm run dev` | Start the demo app (build packages first with `npm run build`) |
-| `npm run build` | Build every package (Vite library mode, then `.d.ts` from JSDoc) |
-| `npm run lint` | ESLint across the repository |
-| `npm run format` / `format:check` | Prettier write / check, including Markdown |
-| `npm test` / `test:coverage` | Vitest; coverage thresholds live in `vitest.config.js` |
-| `npm run check:packages` | `publint` and `@arethetypeswrong/cli` on each built package |
+| Script | Purpose |
+| --------------------------------- | ----------------------------------------------------------------------------------------- |
+| `npm run build` | Build every package (Vite library mode, then `.d.ts` from JSDoc); the engine builds first |
+| `npm run dev` | Start the demo in `examples/demo` (build packages first) |
+| `npm run lint` | ESLint across the repository |
+| `npm run format` / `format:check` | Prettier write / check, including Markdown |
+| `npm test` / `test:coverage` | Vitest; coverage thresholds live in `vitest.config.js` |
+| `npm run check:packages` | `publint` and `@arethetypeswrong/cli` on each built package |
## Conventions
- Plain JavaScript (`.jsx` for components) with ES modules. Types are written as JSDoc and checked through `jsconfig.json`.
- Each package declares `exports`, `types`, `files` and `sideEffects: false`, and builds and tests on its own. Its test environment is set in its `vite.config.js`.
- React and `react-dom` are `peerDependencies` (`^18 || ^19`), never bundled. `npm run check:packages` fails if a build contains React.
-- Components and hooks are named exports and document props with JSDoc typedefs. Styles are CSS modules, shipped as one `style.css` that consumers import (`@scope/pkg/style.css`).
+- Components and hooks are named exports and document props with JSDoc typedefs. Styles are CSS modules, shipped as one `style.css` that consumers import (`@coldsofttech/saccade-keyboards/style.css`).
- Packages are client-only unless stated otherwise. If an entry needs `"use client"`, make sure the build keeps the directive.
- Tests use Vitest, jsdom, Testing Library and `vitest-axe`; `vitest.setup.js` renders everything in StrictMode. Query by role and label.
- Packages never import from an application.
- Prettier owns formatting for all files. markdownlint is not used.
+## Adding a language
+
+See [adding-a-language.md](adding-a-language.md) for a new language, a new arrangement or a new region.
+
## Keeping up with the JS template
This repo is created from `js-SaccadeKeyboards` and receives no automatic updates. When a fix lands there:
1. Compare the changed files with this repo and apply the fix by hand.
-2. Re-check the files this template extends: `eslint.config.js`, `vitest.config.js`, `jsconfig.json`, `.github/workflows/ci.yml`, the example package and `examples/demo`.
+2. Re-check the files this template extends: `eslint.config.js`, `vitest.config.js`, `jsconfig.json`, and `.github/workflows/ci.yml`.
3. Note the change in `CHANGELOG.md`.
## CI
-`ci.yml` runs `lint`, `test` (Node 22, 24, 26 on Ubuntu; Node 24 on Windows and macOS, all with React 19; plus Node 24 with React 18) and `build`. `build` also builds the demo, checks tarball contents and reports gzipped bundle size against `.size-limit.json`. The final `ci` job is the single required check for the `main` ruleset. `audit.yml` runs `npm audit` weekly and when `package-lock.json` changes.
+`ci.yml` runs `lint`, `test` (Node 22, 24, 26 on Ubuntu; Node 24 on Windows and macOS, all with React 19; plus Node 24 with React 18) and `build`. `build` also checks tarball contents, builds the demo against the built packages and reports gzipped bundle size against `.size-limit.json`. A new language package needs an entry in `.size-limit.json`. The final `ci` job is the single required check for the `main` ruleset. `audit.yml` runs `npm audit` weekly and when `package-lock.json` changes.
## Release
1. Set the new `version` in the package's `package.json` and merge it.
-2. Publish a GitHub release tagged `@`, for example `@scope/example-react-package@1.0.0`.
+2. Publish a GitHub release tagged `@`, for example `@coldsofttech/saccade-keyboards@1.0.0`.
3. `release.yml` checks the tag against the package version, builds, and publishes to npm with provenance through the `npm` environment. See [repo-setup.md](repo-setup.md) for the one-time npm setup.
## Cleanup
diff --git a/docs/layout-format.md b/docs/layout-format.md
new file mode 100644
index 0000000..5be9b34
--- /dev/null
+++ b/docs/layout-format.md
@@ -0,0 +1,63 @@
+# Layout format
+
+A keyboard layout is plain data. The keyboard draws it and handles the key presses, so adding a language never needs a change to the engine. A layout has no rendering code.
+
+```js
+export const layout = {
+ id: 'hi',
+ name: 'Hindi',
+ dir: 'ltr',
+ fonts: ['Noto Sans Devanagari'],
+ layers: { letters: [/* rows */], symbols: [/* rows */] },
+ names: { '।': 'danda' },
+};
+```
+
+| Field | Required | Meaning |
+| --------- | -------- | ----------------------------------------------------------------------------------------------------------- |
+| `id` | Yes | Language code. It becomes the `lang` of the keyboard and the package name, `@coldsofttech/saccade-`. |
+| `name` | Yes | Name of the language, used for the default label "`` on-screen keyboard". |
+| `dir` | Yes | Reading direction, `ltr` or `rtl`. The keys themselves always sit left to right, like the physical keys. |
+| `fonts` | No | Font families the keys need. Fonts are never bundled; the app loads them and the layout package lists them. |
+| `layers` | Yes | Layer name to rows of keys. The first layer is shown first. |
+| `decimal` | No | The sign of the decimal key of the number pad (`.`). Spain, for one, uses `,`. |
+| `names` | No | What a screen reader says for a character, merged over the built-in punctuation names. |
+
+## Keys
+
+A row is a list of keys. `width` is the share of the row a key takes; 1 is an ordinary key. A row always fills the width of the keyboard. `rowSpan` makes a key taller: `{ type: 'enter', rowSpan: 2 }` at the end of a row also covers the row below, which then shares only the space that is left. Use it for a tall Enter at the start or the end of a row.
+
+| Key | What it does |
+| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `{ type: 'text', value, shift?, altgr?, capital? }` | Types `value`; `shift` while shift is on, or `altgr` after AltGr (capitalised with shift if it is a letter), or `capital` while caps lock is on (É on the é key of a French keyboard, which types 2 with shift). |
+| `{ type: 'shift' }` | Shift: capitals for the next key only. |
+| `{ type: 'caps' }` | Caps lock: capitals until it is pressed again. |
+| `{ type: 'numlock' }` | Num Lock of the number pad: its `numpad` keys are off while it is off. |
+| `{ type: 'altgr' }` | AltGr: the next key types its `altgr` character. |
+| `{ type: 'space' }` | Types a space. |
+| `{ type: 'backspace' }`, `{ type: 'enter' }` | Reported through `onKey` for the app to apply. |
+| `{ type: 'close' }` | Calls `onClose`. |
+| `{ type: 'layer', to, label, name? }` | Shows another layer and turns shift off. |
+
+The `currencypad` prop of `Keyboard` adds a currency key before every space key at render time, taking 1 unit of the space key's width, so layouts never include it. It shows or hides a row of currency signs above the keys.
+
+A sign that only sits on a letter, such as a vowel sign or a virama, is shown on a dotted circle automatically.
+
+## Helpers
+
+`@coldsofttech/saccade-keyboards/core` exports helpers to write layouts briefly: `keys`, `cased`, `physicalRows`, `numpadRows`, `bottomRow`, `symbolsLayer`, `shiftKey`, `capsKey`, `altGrKey`, `backspaceKey` and `PUNCTUATION_NAMES`.
+
+## Checking a layout
+
+`validateLayout(layout)` returns a list of problems, empty when the layout is valid. Every layout package should assert this in its tests. The `language` prop of the meta `Keyboard` runs it as well and shows the problems instead of a broken keyboard.
+
+## Adding a language
+
+1. Create a package `@coldsofttech/saccade-` that exports the layout as `layout` and depends on `@coldsofttech/saccade-keyboards` as a peer.
+2. Add a test that runs `validateLayout`.
+3. List its font (if any) in `fonts` and document how to load it.
+4. Apps register it with `registerLanguage('', () => import('@coldsofttech/saccade-'))` so `language=""` finds it, or pass the layout through the `layout` prop.
+
+`@coldsofttech/saccade-en` is the reference: it exports `layout` (QWERTY) and `createLayout(type, region)`.
+
+Composition rules (conjuncts, vowel signs joining a consonant) are not part of the format yet. A key types exactly its `value`.
diff --git a/docs/repo-setup.md b/docs/repo-setup.md
index 06bd633..41d214b 100644
--- a/docs/repo-setup.md
+++ b/docs/repo-setup.md
@@ -1,25 +1,16 @@
# Repository setup
-Template repositories copy files only, not settings. After creating a repo from this template, apply the settings below.
-
-## Automated
-
-```bash
-npm run repo:settings # current repo
-npm run repo:settings -- owner/repo # a specific repo
-```
-
-This configures squash-only merging, delete branch on merge, the `skip-changelog` label, secret scanning with push protection, Dependabot alerts and security updates, CodeQL default setup, and the `main` ruleset from `.github/rulesets/main.json`. Requires the `gh` CLI with admin rights.
+The repository settings below are applied by hand in the GitHub UI, or with the `gh` CLI. The `main` ruleset is stored in `.github/rulesets/main.json` and can be imported from Settings > Rules.
## Protections
| Setting | Value | How |
| ------------------------------- | ------------------------ | --------------------------------------------- |
| Dependency graph | On | Default for public repos |
-| Dependabot alerts | On | Script |
-| Dependabot security updates | On | Script |
-| Secret protection | On, with push protection | Script |
-| CodeQL | Default setup | Script |
+| Dependabot alerts | On | Settings > Advanced Security |
+| Dependabot security updates | On | Settings > Advanced Security |
+| Secret protection | On, with push protection | Settings > Advanced Security |
+| CodeQL | Default setup | Settings > Advanced Security |
| Malware alerts | On | Manual: Settings > Advanced Security |
| Copilot Autofix | Off | Manual: Settings > Advanced Security > CodeQL |
| Private vulnerability reporting | On | Manual: Settings > Advanced Security |
@@ -37,9 +28,9 @@ Repository admins can bypass the ruleset through a pull request. This lets a sol
## Manual steps
-- Tick **Template repository** on this repo (Settings > General).
-- Run the **Init** workflow (Actions tab) to replace the template owner and repository name, merge its pull request, then delete `.github/workflows/init.yml`. It requires "Allow GitHub Actions to create and approve pull requests", which the settings script turns on. If the owner is an organization, change `.github/CODEOWNERS` to a team.
-- Fill in the contact method in `CODE_OF_CONDUCT.md`.
+- Allow squash merging only and delete head branches after merge (Settings > General). Create the `skip-changelog` label used by `.github/release.yml`, and the `language`, `region` and `layout` labels used by the issue forms (a label that does not exist is not applied).
+- If the owner is an organization, change `.github/CODEOWNERS` to a team.
- Run `pre-commit install --hook-type pre-commit --hook-type post-commit` locally (the post-commit type is needed for Sweepito cleanup).
- Publish to npm with trusted publishing (no token): create a GitHub environment named `npm` (Settings > Environments), then on npmjs.com open each package's Settings > Trusted publisher and register this repository, the workflow `release.yml` and the `npm` environment. A package must exist on npm before it can be configured, so publish the first version manually with `npm publish --access public`, then register the trusted publisher.
-- CodeQL default setup only works once the repo contains a supported language, so run the script after adding code.
+- There are 7 packages to publish: `saccade-keyboards` and the languages `en`, `es`, `fr`, `hi`, `te` and `ur`. Each needs its own trusted publisher on npm. A new language package needs the same.
+- CodeQL default setup only works once the repo contains a supported language, so enable it after adding code.
diff --git a/docs/security.md b/docs/security.md
index 95ee210..dea4309 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -1,6 +1,16 @@
# Security
-How this template catches security problems, from the editor to a published package. To report a vulnerability, see [SECURITY.md](../SECURITY.md).
+How Saccade Keyboards stays safe next to text that people type, and how security problems are caught, from the editor to a published package. To report a vulnerability, see [SECURITY.md](../SECURITY.md).
+
+## Handling typed text
+
+The keyboard sits next to fields where people may type passwords or private messages, so it keeps to a few rules.
+
+- It reports a key press only through `onKey`. It stores nothing and sends nothing anywhere.
+- It never reads or changes the field. The host app applies the key, for example with `applyKey`.
+- It makes no network requests. A language is a package the app installs and loads, and a layout is plain data that `validateLayout` checks before it is used.
+- Layout text is rendered as text, never as HTML.
+- Styles are scoped CSS modules, so the keyboard cannot restyle the host page.
## Layers
diff --git a/docs/third-party-notices.md b/docs/third-party-notices.md
new file mode 100644
index 0000000..1b80e09
--- /dev/null
+++ b/docs/third-party-notices.md
@@ -0,0 +1,25 @@
+# Third-party notices
+
+Saccade Keyboards is released under the [MIT license](../LICENSE). This page lists what its packages depend on at runtime and the licenses they use. Development tools (test runners, linters, build tools) are not shipped and are not listed.
+
+None of the packages has a runtime dependency, so there are no third-party licenses to carry along with them.
+
+## Runtime dependencies
+
+| Package | License | Runtime dependencies |
+| --------------------------------- | ------- | -------------------------------- |
+| `@coldsofttech/saccade-keyboards` | MIT | None |
+| `@coldsofttech/saccade-en` | MIT | None (peer: `saccade-keyboards`) |
+| `@coldsofttech/saccade-es` | MIT | None (peer: `saccade-keyboards`) |
+| `@coldsofttech/saccade-fr` | MIT | None (peer: `saccade-keyboards`) |
+| `@coldsofttech/saccade-hi` | MIT | None (peer: `saccade-keyboards`) |
+| `@coldsofttech/saccade-te` | MIT | None (peer: `saccade-keyboards`) |
+| `@coldsofttech/saccade-ur` | MIT | None (peer: `saccade-keyboards`) |
+
+`react` and `react-dom` (18 or 19) are peer dependencies of `@coldsofttech/saccade-keyboards` that your app provides. They are never bundled, and they are released under the MIT license by their own authors.
+
+## Notes
+
+- **Fonts.** No font is bundled. The keys use the fonts of the device by default. If you load a font for a script, for example a Noto font, that font has its own license, which you accept when you add it to your app.
+- **Layouts.** The keyboard layouts are plain data written for this project. They describe common keyboards and published layouts such as QWERTY, AZERTY and InScript, and contain no code from other projects.
+- **Demo and tests.** The demo app and the tests use React, Vite and Vitest, which are development tools and are not part of any published package.
diff --git a/examples/demo/index.html b/examples/demo/index.html
index 5321f20..12fc63d 100644
--- a/examples/demo/index.html
+++ b/examples/demo/index.html
@@ -3,7 +3,7 @@
- Demo
+ Saccade Keyboards demo
diff --git a/examples/demo/package.json b/examples/demo/package.json
index 8b46076..5321af5 100644
--- a/examples/demo/package.json
+++ b/examples/demo/package.json
@@ -8,7 +8,13 @@
"build": "vite build"
},
"dependencies": {
- "@scope/example-react-package": "*",
+ "@coldsofttech/saccade-en": "*",
+ "@coldsofttech/saccade-es": "*",
+ "@coldsofttech/saccade-fr": "*",
+ "@coldsofttech/saccade-hi": "*",
+ "@coldsofttech/saccade-keyboards": "*",
+ "@coldsofttech/saccade-te": "*",
+ "@coldsofttech/saccade-ur": "*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
diff --git a/examples/demo/src/main.jsx b/examples/demo/src/main.jsx
index 2eb0ba6..22ee40c 100644
--- a/examples/demo/src/main.jsx
+++ b/examples/demo/src/main.jsx
@@ -1,13 +1,151 @@
-import { StrictMode } from 'react';
+import { StrictMode, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
-import { ExampleComponent } from '@scope/example-react-package';
-import '@scope/example-react-package/style.css';
-
-const root = document.getElementById('root');
-if (root) {
- createRoot(root).render(
-
-
- ,
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { applyKey } from '@coldsofttech/saccade-keyboards/core';
+import '@coldsofttech/saccade-keyboards/style.css';
+import { createLayout as createEnglish } from '@coldsofttech/saccade-en';
+import { createLayout as createSpanish } from '@coldsofttech/saccade-es';
+import { createLayout as createFrench } from '@coldsofttech/saccade-fr';
+import { createLayout as createHindi } from '@coldsofttech/saccade-hi';
+import { createLayout as createTelugu } from '@coldsofttech/saccade-te';
+import { createLayout as createUrdu } from '@coldsofttech/saccade-ur';
+
+const LAYOUTS = new Map([
+ ['English', createEnglish],
+ ['Spanish', createSpanish],
+ ['French', createFrench],
+ // Hindi has no regions: the keyboard is InScript or phonetic and the region picks the digits.
+ ['Hindi', createHindi],
+ ['Telugu', createTelugu],
+ ['Urdu', createUrdu],
+]);
+const LANGUAGES = [...LAYOUTS.keys()];
+const REGIONS = new Map([
+ ['English', ['US', 'GB', 'AU', 'CA', 'IN']],
+ ['Spanish', ['ES', 'MX', 'AR', 'CO', 'US', 'LATAM']],
+ ['French', ['FR', 'BE', 'CA', 'CH']],
+ ['Hindi', ['latin', 'devanagari']],
+ ['Telugu', ['latin', 'telugu']],
+ ['Urdu', ['latin', 'urdu']],
+]);
+// `native` is the order of the keyboard of the region: QWERTY, AZERTY and so on.
+const ARRANGEMENTS = ['native', 'alphabetical'];
+const HINDI_ARRANGEMENTS = ['inscript', 'phonetic'];
+const THEMES = ['light', 'dark'];
+const FONTS = ['native', 'sans', 'serif', 'mono'];
+// The keyboard fills the element it sits in, so the demo sets its width on a wrapper.
+const WIDTHS = ['full', '430 px', '600 px', '800 px', '1000 px'];
+
+function Select({ label, value, options, onChange }) {
+ return (
+
);
}
+
+function Demo() {
+ const field = useRef(null);
+ const [language, setLanguage] = useState('English');
+ const [chosenRegion, setRegion] = useState('US');
+ const [arrangement, setArrangement] = useState('native');
+ const [theme, setTheme] = useState('light');
+ const [font, setFont] = useState('native');
+ const [fontSize, setFontSize] = useState(20);
+ const [width, setWidth] = useState('full');
+ const [shown, setShown] = useState(true);
+ const [numpad, setNumpad] = useState(false);
+ const [currencypad, setCurrencypad] = useState(false);
+
+ // A region from the other language falls back to that language's first one.
+ const arrangements = ['Hindi', 'Telugu'].includes(language) ? HINDI_ARRANGEMENTS : ARRANGEMENTS;
+ const letters = arrangements.includes(arrangement) ? arrangement : arrangements[0];
+ const regions = REGIONS.get(language);
+ const region = regions.includes(chosenRegion) ? chosenRegion : regions[0];
+
+ const onKey = (key) => {
+ const { value, selectionStart, selectionEnd } = field.current;
+ const next = applyKey(value, { start: selectionStart, end: selectionEnd }, key);
+ field.current.value = next.text;
+ field.current.setSelectionRange(next.selection.start, next.selection.end);
+ };
+
+ return (
+
+ {language} keyboard
+ Click in the field, then press keys on the on-screen keyboard.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+);
diff --git a/package-lock.json b/package-lock.json
index 958b8f5..59bac1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,7 +45,13 @@
"examples/demo": {
"version": "0.0.0",
"dependencies": {
- "@scope/example-react-package": "*",
+ "@coldsofttech/saccade-en": "*",
+ "@coldsofttech/saccade-es": "*",
+ "@coldsofttech/saccade-fr": "*",
+ "@coldsofttech/saccade-hi": "*",
+ "@coldsofttech/saccade-keyboards": "*",
+ "@coldsofttech/saccade-te": "*",
+ "@coldsofttech/saccade-ur": "*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@@ -490,6 +496,34 @@
"keyv": "^5.6.0"
}
},
+ "node_modules/@coldsofttech/saccade-en": {
+ "resolved": "packages/saccade-en",
+ "link": true
+ },
+ "node_modules/@coldsofttech/saccade-es": {
+ "resolved": "packages/saccade-es",
+ "link": true
+ },
+ "node_modules/@coldsofttech/saccade-fr": {
+ "resolved": "packages/saccade-fr",
+ "link": true
+ },
+ "node_modules/@coldsofttech/saccade-hi": {
+ "resolved": "packages/saccade-hi",
+ "link": true
+ },
+ "node_modules/@coldsofttech/saccade-keyboards": {
+ "resolved": "packages/saccade-keyboards",
+ "link": true
+ },
+ "node_modules/@coldsofttech/saccade-te": {
+ "resolved": "packages/saccade-te",
+ "link": true
+ },
+ "node_modules/@coldsofttech/saccade-ur": {
+ "resolved": "packages/saccade-ur",
+ "link": true
+ },
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -1243,10 +1277,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@scope/example-react-package": {
- "resolved": "packages/example-react-package",
- "link": true
- },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -2994,9 +3024,9 @@
}
},
"node_modules/eslint": {
- "version": "10.13.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
- "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
+ "version": "10.14.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
+ "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -3009,7 +3039,7 @@
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.3",
- "@humanfs/node": "^0.16.6",
+ "@humanfs/node": "^0.16.8",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
@@ -3030,7 +3060,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.5",
+ "minimatch": "^10.2.6",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -6742,8 +6772,52 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
- "packages/example-react-package": {
- "name": "@scope/example-react-package",
+ "packages/saccade-en": {
+ "name": "@coldsofttech/saccade-en",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+ },
+ "packages/saccade-es": {
+ "name": "@coldsofttech/saccade-es",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+ },
+ "packages/saccade-fr": {
+ "name": "@coldsofttech/saccade-fr",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+ },
+ "packages/saccade-hi": {
+ "name": "@coldsofttech/saccade-hi",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+ },
+ "packages/saccade-keyboards": {
+ "name": "@coldsofttech/saccade-keyboards",
"version": "0.1.0",
"license": "MIT",
"engines": {
@@ -6753,6 +6827,28 @@
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
+ },
+ "packages/saccade-te": {
+ "name": "@coldsofttech/saccade-te",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+ },
+ "packages/saccade-ur": {
+ "name": "@coldsofttech/saccade-ur",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index 1ca2248..0387ea8 100644
--- a/package.json
+++ b/package.json
@@ -10,15 +10,14 @@
"examples/*"
],
"scripts": {
+ "build": "npm run build -w @coldsofttech/saccade-keyboards && npm run build --workspaces --if-present",
"dev": "npm run dev --workspace examples/demo",
- "build": "npm run build --workspaces --if-present",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"check:packages": "npm run check:packages --workspaces --if-present",
- "repo:settings": "node scripts/apply-repo-settings.js",
"size": "size-limit"
},
"devDependencies": {
diff --git a/packages/example-react-package/README.md b/packages/example-react-package/README.md
deleted file mode 100644
index 480a630..0000000
--- a/packages/example-react-package/README.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# @scope/example-package
-
-A small example package. Replace this text with what your package does.
-
-## Install
-
-```bash
-npm install @scope/example-package
-```
-
-## Usage
-
-```js
-import { greet } from '@scope/example-package';
-
-greet('World'); // "Hello, World!"
-```
diff --git a/packages/example-react-package/src/ExampleComponent.jsx b/packages/example-react-package/src/ExampleComponent.jsx
deleted file mode 100644
index fcbfaaf..0000000
--- a/packages/example-react-package/src/ExampleComponent.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import styles from './ExampleComponent.module.css';
-
-/**
- * @typedef {object} ExampleComponentProps
- * @property {string} name Who to greet.
- * @property {(name: string) => void} [onGreet] Called with the name when the button is pressed.
- */
-
-/**
- * Shows a greeting and a button that reports the greeted name to the host.
- *
- * @param {ExampleComponentProps} props Component props.
- * @returns {import('react').JSX.Element} The rendered greeting.
- */
-export function ExampleComponent({ name, onGreet }) {
- return (
-
- Hello, {name}!
-
-
- );
-}
diff --git a/packages/example-react-package/src/ExampleComponent.module.css b/packages/example-react-package/src/ExampleComponent.module.css
deleted file mode 100644
index dd30527..0000000
--- a/packages/example-react-package/src/ExampleComponent.module.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.root {
- display: inline-flex;
- flex-direction: column;
- gap: 0.5rem;
-}
diff --git a/packages/example-react-package/src/ExampleComponent.test.jsx b/packages/example-react-package/src/ExampleComponent.test.jsx
deleted file mode 100644
index 1dc2f30..0000000
--- a/packages/example-react-package/src/ExampleComponent.test.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { axe } from 'vitest-axe';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { ExampleComponent } from './index.js';
-
-describe('ExampleComponent', () => {
- it('greets by name', () => {
- render( );
- expect(screen.getByText('Hello, World!')).toBeInTheDocument();
- });
-
- it('reports the name when the button is pressed', async () => {
- const onGreet = vi.fn();
- render( );
- await userEvent.click(screen.getByRole('button', { name: 'Greet' }));
- expect(onGreet).toHaveBeenCalledWith('World');
- });
-
- it('has no accessibility violations', async () => {
- const { container } = render( );
- expect(await axe(container)).toHaveNoViolations();
- });
-});
diff --git a/packages/example-react-package/src/index.js b/packages/example-react-package/src/index.js
deleted file mode 100644
index f0cc572..0000000
--- a/packages/example-react-package/src/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export { ExampleComponent } from './ExampleComponent.jsx';
-export { useGreeting } from './useGreeting.js';
diff --git a/packages/example-react-package/src/useGreeting.js b/packages/example-react-package/src/useGreeting.js
deleted file mode 100644
index 7b2c28c..0000000
--- a/packages/example-react-package/src/useGreeting.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { useMemo } from 'react';
-
-/**
- * Builds a greeting for the given name.
- *
- * @param {string} name Who to greet.
- * @returns {string} The greeting.
- */
-export function useGreeting(name) {
- return useMemo(() => `Hello, ${name}!`, [name]);
-}
diff --git a/packages/example-react-package/src/useGreeting.test.jsx b/packages/example-react-package/src/useGreeting.test.jsx
deleted file mode 100644
index 06c0cc9..0000000
--- a/packages/example-react-package/src/useGreeting.test.jsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { renderHook } from '@testing-library/react';
-import { useGreeting } from './index.js';
-
-describe('useGreeting', () => {
- it('builds the greeting and updates with the name', () => {
- const { result, rerender } = renderHook(({ name }) => useGreeting(name), {
- initialProps: { name: 'World' },
- });
- expect(result.current).toBe('Hello, World!');
- rerender({ name: 'React' });
- expect(result.current).toBe('Hello, React!');
- });
-});
diff --git a/packages/saccade-en/README.md b/packages/saccade-en/README.md
new file mode 100644
index 0000000..f1c5b9b
--- /dev/null
+++ b/packages/saccade-en/README.md
@@ -0,0 +1,34 @@
+# @coldsofttech/saccade-en
+
+The English keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), laid out like a physical keyboard: a number row with its symbols, Caps Lock, two Shift keys, a tall Enter on British keyboards, and a bottom row with the space bar. The AltGr key types what the keyboard of the region types with it: the United States-International characters (accents, ñ, €, ¥, © and more) for the US, Australia and Canada, € ¦ and the accented vowels for Britain, and ₹ for India. Letters can be in QWERTY order (the default) or in alphabetical order.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-en
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('en', () => import('@coldsofttech/saccade-en'));
+
+ ;
+```
+
+## Alphabetical order
+
+```jsx
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { createLayout } from '@coldsofttech/saccade-en';
+
+ ;
+```
+
+`createLayout(type, region)` takes the letter order, `'qwerty'` (default) or `'alphabetical'`, and the country: `'US'` (default), `'GB'` (`'UK'` also works), `'AU'`, `'CA'` or `'IN'`.
+
+```jsx
+
+```
+
+The country sets the language tag (`en-GB`), sets the physical keys (a British keyboard has a tall Enter and `"` above `2`) and what AltGr types.
diff --git a/packages/saccade-en/package.json b/packages/saccade-en/package.json
new file mode 100644
index 0000000..6295269
--- /dev/null
+++ b/packages/saccade-en/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-en",
+ "version": "0.1.0",
+ "description": "English keyboard layout for @coldsofttech/saccade-keyboards, in QWERTY or alphabetical order.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-en"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-en/src/index.js b/packages/saccade-en/src/index.js
new file mode 100644
index 0000000..38b94f5
--- /dev/null
+++ b/packages/saccade-en/src/index.js
@@ -0,0 +1,150 @@
+import { altGrKey, keys, physicalRows } from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+
+/**
+ * How the letters are arranged: `qwerty` like a physical keyboard, or `alphabetical` from a to z.
+ * Every other key stays where it is on the physical keyboard.
+ *
+ * @typedef {'qwerty' | 'alphabetical'} Arrangement
+ */
+
+/** The letters of the three rows of each arrangement. */
+const ARRANGEMENTS = new Map([
+ ['qwerty', ['qwertyuiop', 'asdfghjkl', 'zxcvbnm']],
+ ['alphabetical', ['abcdefghij', 'klmnopqrs', 'tuvwxyz']],
+]);
+
+/**
+ * The country the keyboard is for: `US`, `GB` (`UK` is accepted too), `AU`, `CA` or `IN`.
+ *
+ * @typedef {'US' | 'GB' | 'UK' | 'AU' | 'CA' | 'IN'} Region
+ */
+
+/**
+ * What AltGr types on the United States-International keyboard, which the US, Australia and
+ * Canada use for the accents and signs. A plain US keyboard has no AltGr. Keys are named by what
+ * they type.
+ */
+const INTERNATIONAL = {
+ 1: '¡',
+ 2: '²',
+ 3: '³',
+ 4: '¤',
+ 5: '€',
+ 6: '¼',
+ 7: '½',
+ 8: '¾',
+ 9: '‘',
+ 0: '’',
+ '-': '¥',
+ '=': '×',
+ q: 'ä',
+ w: 'å',
+ e: 'é',
+ r: '®',
+ t: 'þ',
+ y: 'ü',
+ u: 'ú',
+ i: 'í',
+ o: 'ó',
+ p: 'ö',
+ '[': '«',
+ ']': '»',
+ '\\': '¬',
+ a: 'á',
+ s: 'ß',
+ d: 'ð',
+ l: 'ø',
+ ';': '¶',
+ "'": '´',
+ z: 'æ',
+ c: '©',
+ n: 'ñ',
+ m: 'µ',
+ ',': 'ç',
+ '/': '¿',
+};
+
+/** What AltGr types on a British keyboard: the euro, the broken bar and the acute accents. */
+const BRITISH = { '`': '¦', 4: '€', a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú' };
+
+/** What AltGr types on an Indian English keyboard: the rupee sign. */
+const INDIAN = { 4: '₹' };
+
+/** The keys of a US (ANSI) keyboard, which Australia, Canada and India share for English. */
+const ANSI = {
+ iso: false,
+ digits: ['`1234567890-=', '~!@#$%^&*()_+'],
+ end2: ['[]\\', '{}|'],
+ end3: [";'", ':"'],
+ end4: [',./', '<>?'],
+};
+
+/** The keys of a British (ISO) keyboard: a tall Enter, a hash key and a second backslash key. */
+const ISO = {
+ iso: true,
+ digits: ['`1234567890-=', '¬!"£$%^&*()_+'],
+ end2: ['[]', '{}'],
+ end3: [";'#", ':@~'],
+ extra4: ['\\', '|'],
+ end4: [',./', '<>?'],
+};
+
+/** What differs by country: the name, the physical keys and what AltGr types. */
+const REGIONS = new Map([
+ ['US', { name: 'English (US)', keyboard: ANSI, altgr: INTERNATIONAL }],
+ ['GB', { name: 'English (GB)', keyboard: ISO, altgr: BRITISH }],
+ ['AU', { name: 'English (AU)', keyboard: ANSI, altgr: INTERNATIONAL }],
+ ['CA', { name: 'English (CA)', keyboard: ANSI, altgr: INTERNATIONAL }],
+ ['IN', { name: 'English (IN)', keyboard: ANSI, altgr: INDIAN }],
+]);
+
+/**
+ * The English layout in the given arrangement, for a country.
+ *
+ * @param {Arrangement} [type] The arrangement of the letters.
+ * @param {Region} [region] The country, which sets the physical keys and the currency signs.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'qwerty', region = 'US') {
+ const code = region === 'UK' ? 'GB' : region;
+ const country = REGIONS.get(code);
+ if (!country) {
+ throw new Error(
+ `Unknown region "${region}". Use ${[...REGIONS.keys()].map((name) => `"${name}"`).join(', ')} or "UK".`,
+ );
+ }
+ const letters = ARRANGEMENTS.get(type);
+ if (!letters) {
+ throw new Error(
+ `Unknown arrangement "${type}". Use ${[...ARRANGEMENTS.keys()]
+ .map((name) => `"${name}"`)
+ .join(' or ')}.`,
+ );
+ }
+ return {
+ id: `en-${code}`,
+ name: country.name,
+ dir: 'ltr',
+ layers: {
+ letters: [
+ ...physicalRows({ ...country.keyboard, altgr: country.altgr, letters }),
+ [
+ { ...altGrKey, width: 1.5 },
+ ...keys('?!'),
+ { type: 'space', width: 5 },
+ ...keys('@_'),
+ { type: 'close', width: 1.5 },
+ ],
+ ],
+ },
+ };
+}
+
+/**
+ * The US English layout in QWERTY order, which is what `language="en"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-en/src/index.test.js b/packages/saccade-en/src/index.test.js
new file mode 100644
index 0000000..d7347cf
--- /dev/null
+++ b/packages/saccade-en/src/index.test.js
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const letters = (l) =>
+ l.layers.letters
+ .flat()
+ .filter((key) => key.type === 'text' && /^[a-z]$/.test(key.value))
+ .map((key) => key.value);
+
+describe('saccade-en', () => {
+ it('is a valid layout in QWERTY order by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('en-US');
+ expect(letters(layout).slice(0, 10).join('')).toBe('qwertyuiop');
+ });
+
+ it.each(['qwerty', 'alphabetical'])('has all 26 letters once in %s', (type) => {
+ const built = createLayout(type);
+ expect(validateLayout(built)).toEqual([]);
+ expect([...letters(built)].sort().join('')).toBe('abcdefghijklmnopqrstuvwxyz');
+ });
+
+ it('puts the letters in order when alphabetical', () => {
+ expect(letters(createLayout('alphabetical')).join('')).toBe('abcdefghijklmnopqrstuvwxyz');
+ });
+
+ it('types capitals with shift', () => {
+ const key = layout.layers.letters[1][0];
+ expect(key).toMatchObject({ value: 'q', shift: 'Q' });
+ });
+
+ it.each([
+ ['US', '5', '€'],
+ ['GB', '4', '€'],
+ ['AU', '5', '€'],
+ ['CA', '5', '€'],
+ ['IN', '4', '₹'],
+ ])('is valid for %s, with its currency sign on AltGr', (region, key, sign) => {
+ const built = createLayout('qwerty', region);
+ expect(validateLayout(built)).toEqual([]);
+ expect(built.id).toBe(`en-${region}`);
+ expect(altgr(built, key)).toBe(sign);
+ });
+
+ const shifted = (l, value) => l.layers.letters.flat().find((key) => key.value === value)?.shift;
+ const altgr = (l, value) => l.layers.letters.flat().find((key) => key.value === value)?.altgr;
+
+ it('has the symbols of each region on the number row', () => {
+ expect(shifted(layout, '2')).toBe('@');
+ expect(shifted(createLayout('qwerty', 'GB'), '2')).toBe('"');
+ expect(shifted(createLayout('qwerty', 'GB'), '3')).toBe('£');
+ expect(shifted(createLayout('qwerty', 'GB'), "'")).toBe('@');
+ });
+
+ it('accepts UK as GB', () => {
+ expect(createLayout('qwerty', 'UK').id).toBe('en-GB');
+ });
+
+ it('starts with the number row and ends with backspace', () => {
+ const [first] = layout.layers.letters;
+ expect(first.map((key) => key.value ?? key.type).join('')).toBe('`1234567890-=backspace');
+ expect(Object.keys(layout.layers)).toEqual(['letters']);
+ });
+
+ it('has the AltGr characters of the keyboard of each region', () => {
+ const us = ['a', 'e', 'i', 'o', 'u', 'n', 'c'].map((v) => altgr(layout, v)).join('');
+ expect(us).toBe('áéíóúñ©');
+ const gb = createLayout('qwerty', 'GB');
+ expect(['a', 'e', 'i', 'o', 'u', '`'].map((v) => altgr(gb, v)).join('')).toBe('áéíóú¦');
+ expect(altgr(gb, 'n')).toBeUndefined();
+ expect(altgr(createLayout('qwerty', 'IN'), 'a')).toBeUndefined();
+ });
+
+ it('keeps AltGr on the letter when the letters are alphabetical', () => {
+ expect(altgr(createLayout('alphabetical'), 'e')).toBe('é');
+ });
+
+ it('has a tall Enter beside rows 2 and 3 on a British keyboard, and Enter on row 3 on a US one', () => {
+ const [, row2, row3] = createLayout('qwerty', 'GB').layers.letters;
+ expect(row2.at(-1)).toMatchObject({ type: 'enter', rowSpan: 2 });
+ expect(row3.some((key) => key.type === 'enter')).toBe(false);
+ const [, usRow2, usRow3] = layout.layers.letters;
+ expect(usRow2.some((key) => key.type === 'enter')).toBe(false);
+ expect(usRow3.at(-1).type).toBe('enter');
+ });
+
+ it('has a shift key on each side of the bottom row of letters', () => {
+ const row4 = layout.layers.letters[3];
+ expect(row4.at(0).type).toBe('shift');
+ expect(row4.at(-1).type).toBe('shift');
+ });
+
+ it('ends with an AltGr key, the space bar and a hide key', () => {
+ const last = layout.layers.letters.at(-1);
+ expect(last.at(0).type).toBe('altgr');
+ expect(last.map((key) => key.type)).toContain('space');
+ expect(last.at(-1).type).toBe('close');
+ });
+
+ it('rejects an unknown region', () => {
+ expect(() => createLayout('qwerty', 'FR')).toThrow(/Unknown region "FR"/);
+ });
+
+ it('rejects an unknown arrangement', () => {
+ expect(() => createLayout('dvorak')).toThrow(/Unknown arrangement "dvorak"/);
+ });
+});
diff --git a/packages/example-react-package/tsconfig.build.json b/packages/saccade-en/tsconfig.build.json
similarity index 100%
rename from packages/example-react-package/tsconfig.build.json
rename to packages/saccade-en/tsconfig.build.json
diff --git a/packages/saccade-en/vite.config.js b/packages/saccade-en/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-en/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/packages/saccade-es/README.md b/packages/saccade-es/README.md
new file mode 100644
index 0000000..0ecdc41
--- /dev/null
+++ b/packages/saccade-es/README.md
@@ -0,0 +1,34 @@
+# @coldsofttech/saccade-es
+
+The Spanish keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), laid out like a physical Spanish or Latin American keyboard: a number row with its symbols (including ¿ and ¡), ñ, Caps Lock, two Shift keys and a tall Enter. The AltGr key types the characters a Spanish or Latin American keyboard has under AltGr (@ # \ [ ] { } ~ | €). It also types the accented vowels (á é í ó ú ü), which a real keyboard types with a dead key. Letters can be in QWERTY order (the default) or in alphabetical order.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-es
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('es', () => import('@coldsofttech/saccade-es'));
+
+ ;
+```
+
+## Alphabetical order
+
+```jsx
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { createLayout } from '@coldsofttech/saccade-es';
+
+ ;
+```
+
+`createLayout(type, region)` takes the letter order, `'qwerty'` (default) or `'alphabetical'`, and the country: `'ES'` (default), `'MX'`, `'AR'`, `'CO'`, `'US'` or `'LATAM'`.
+
+```jsx
+
+```
+
+The country sets the language tag (`es-MX`, or `es-419` for Latin America), sets the physical keys (Spanish or Latin American), what AltGr types and the decimal sign of the number pad (`,` in Spain, Argentina and Colombia).
diff --git a/packages/saccade-es/package.json b/packages/saccade-es/package.json
new file mode 100644
index 0000000..5090106
--- /dev/null
+++ b/packages/saccade-es/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-es",
+ "version": "0.1.0",
+ "description": "Spanish keyboard layout for @coldsofttech/saccade-keyboards, in QWERTY or alphabetical order.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-es"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-es/src/index.js b/packages/saccade-es/src/index.js
new file mode 100644
index 0000000..7baedb4
--- /dev/null
+++ b/packages/saccade-es/src/index.js
@@ -0,0 +1,137 @@
+import { altGrKey, keys, physicalRows } from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+
+/**
+ * How the letters are arranged: `qwerty` like a Spanish physical keyboard, or `alphabetical` from a
+ * to z with the ñ after the n. Every other key stays where it is on the physical keyboard.
+ *
+ * @typedef {'qwerty' | 'alphabetical'} Arrangement
+ */
+
+/** The letters of the three rows of each arrangement. The ñ is the last key of the second row. */
+const ARRANGEMENTS = new Map([
+ ['qwerty', ['qwertyuiop', 'asdfghjklñ', 'zxcvbnm']],
+ ['alphabetical', ['abcdefghij', 'klmnñopqrs', 'tuvwxyz']],
+]);
+
+/**
+ * The country the keyboard is for: `ES` (Spain), `MX` (Mexico), `AR` (Argentina), `CO` (Colombia),
+ * `US` (the United States) or `LATAM` (Latin America in general).
+ *
+ * @typedef {'ES' | 'MX' | 'AR' | 'CO' | 'US' | 'LATAM'} Region
+ */
+
+/**
+ * The accented vowels and the ü. A Spanish keyboard types them with a dead key (the accent, then
+ * the vowel), which this keyboard does not have, so AltGr types them in one press.
+ */
+const ACCENTS = { a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', y: 'ü' };
+
+/**
+ * The keys of a Spanish keyboard, an ISO keyboard with the ñ, the accent key and the ç. AltGr types
+ * the characters the main view has no key for: | @ # ~ € ¬ \ [ ] { }.
+ */
+const SPAIN = {
+ iso: true,
+ digits: ["º1234567890'¡", 'ª!"·$%&/()=?¿'],
+ end2: ['`+', '^*'],
+ end3: ['´ç', '¨Ç'],
+ extra4: ['<', '>'],
+ end4: [',.-', ';:_'],
+ altgr: {
+ ...ACCENTS,
+ º: '\\',
+ 1: '|',
+ 2: '@',
+ 3: '#',
+ 4: '~',
+ 5: '€',
+ 6: '¬',
+ '`': '[',
+ '+': ']',
+ '´': '{',
+ ç: '}',
+ },
+};
+
+/**
+ * The keys of a Latin American keyboard, which Spanish speakers in the United States use too.
+ * AltGr types | @ ~ € ¬ \ ^ and the grave accent, which the main view has no key for.
+ */
+const LATIN_AMERICA = {
+ iso: true,
+ digits: ["|1234567890'¿", '°!"#$%&/()=?¡'],
+ end2: ['´+', '¨*'],
+ end3: ['{}', '[]'],
+ extra4: ['<', '>'],
+ end4: [',.-', ';:_'],
+ altgr: { ...ACCENTS, 1: '|', 2: '@', 4: '~', 5: '€', 6: '¬', "'": '\\', '´': '^', '+': '`' },
+};
+
+/** What a screen reader says for the characters Spanish adds. */
+const NAMES = {
+ '¿': 'signo de interrogación de apertura',
+ '¡': 'signo de exclamación de apertura',
+ '€': 'euro',
+};
+
+/** What differs by country: the name, the physical keys and the decimal sign of the number pad. */
+const REGIONS = new Map([
+ ['ES', { name: 'Spanish (ES)', keyboard: SPAIN, decimal: ',' }],
+ ['MX', { name: 'Spanish (MX)', keyboard: LATIN_AMERICA, decimal: '.' }],
+ ['AR', { name: 'Spanish (AR)', keyboard: LATIN_AMERICA, decimal: ',' }],
+ ['CO', { name: 'Spanish (CO)', keyboard: LATIN_AMERICA, decimal: ',' }],
+ ['US', { name: 'Spanish (US)', keyboard: LATIN_AMERICA, decimal: '.' }],
+ ['LATAM', { name: 'Spanish (Latin America)', keyboard: LATIN_AMERICA, decimal: '.' }],
+]);
+
+/**
+ * The Spanish layout in the given arrangement, for a country.
+ *
+ * @param {Arrangement} [type] The arrangement of the letters.
+ * @param {Region} [region] The country, which sets the physical keys.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'qwerty', region = 'ES') {
+ const country = REGIONS.get(region);
+ if (!country) {
+ throw new Error(
+ `Unknown region "${region}". Use ${[...REGIONS.keys()].map((name) => `"${name}"`).join(', ')}.`,
+ );
+ }
+ const letters = ARRANGEMENTS.get(type);
+ if (!letters) {
+ throw new Error(
+ `Unknown arrangement "${type}". Use ${[...ARRANGEMENTS.keys()]
+ .map((name) => `"${name}"`)
+ .join(' or ')}.`,
+ );
+ }
+ return {
+ id: region === 'LATAM' ? 'es-419' : `es-${region}`,
+ name: country.name,
+ dir: 'ltr',
+ decimal: country.decimal,
+ names: NAMES,
+ layers: {
+ letters: [
+ ...physicalRows({ ...country.keyboard, letters }),
+ [
+ { ...altGrKey, width: 1.5 },
+ ...keys('¿?'),
+ { type: 'space', width: 5 },
+ ...keys('¡!'),
+ { type: 'close', width: 1.5 },
+ ],
+ ],
+ },
+ };
+}
+
+/**
+ * The Spanish layout for Spain in QWERTY order, which is what `language="es"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-es/src/index.test.js b/packages/saccade-es/src/index.test.js
new file mode 100644
index 0000000..74a2caf
--- /dev/null
+++ b/packages/saccade-es/src/index.test.js
@@ -0,0 +1,129 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const ALPHABET = 'abcdefghijklmnñopqrstuvwxyz';
+
+const typed = (l, layer) =>
+ new Map(Object.entries(l.layers))
+ .get(layer)
+ .flat()
+ .filter((key) => key.type === 'text')
+ .map((key) => key.value);
+const lettersOf = (l) => typed(l, 'letters').filter((value) => ALPHABET.includes(value));
+const shifted = (l, value) => l.layers.letters.flat().find((key) => key.value === value)?.shift;
+const altgr = (l, value) => l.layers.letters.flat().find((key) => key.value === value)?.altgr;
+
+describe('saccade-es', () => {
+ it('is a valid layout in QWERTY order by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('es-ES');
+ expect(lettersOf(layout).slice(0, 10).join('')).toBe('qwertyuiop');
+ });
+
+ it.each(['qwerty', 'alphabetical'])('has all 27 letters once in %s', (type) => {
+ const built = createLayout(type);
+ expect(validateLayout(built)).toEqual([]);
+ expect(lettersOf(built).sort().join('')).toBe([...ALPHABET].sort().join(''));
+ });
+
+ it('puts the letters in order, with ñ after n, when alphabetical', () => {
+ expect(lettersOf(createLayout('alphabetical')).join('')).toBe(ALPHABET);
+ });
+
+ it('puts ñ after l on the QWERTY layer', () => {
+ expect(lettersOf(layout).join('')).toContain('klñ');
+ });
+
+ it('starts with the number row of a Spanish keyboard and ends it with backspace', () => {
+ const [first] = layout.layers.letters;
+ expect(first.map((key) => key.value ?? key.type).join('')).toBe("º1234567890'¡backspace");
+ expect(shifted(layout, '2')).toBe('"');
+ expect(shifted(layout, '1')).toBe('!');
+ });
+
+ it('has the inverted marks on the number row and next to the space bar', () => {
+ expect(shifted(layout, "'")).toBe('?');
+ expect(shifted(layout, '¡')).toBe('¿');
+ const last = layout.layers.letters.at(-1).map((key) => key.value ?? key.type);
+ expect(last).toEqual(expect.arrayContaining(['¿', '¡', 'space']));
+ });
+
+ it('has the accented vowels and the ü on AltGr, capitalised with shift', () => {
+ expect(['a', 'e', 'i', 'o', 'u', 'y'].map((value) => altgr(layout, value)).join('')).toBe(
+ 'áéíóúü',
+ );
+ expect(Object.keys(layout.layers)).toEqual(['letters']);
+ });
+
+ it('has what a Spanish keyboard types with AltGr', () => {
+ expect(['º', '1', '2', '3', '4', '5'].map((value) => altgr(layout, value)).join('')).toBe(
+ '\\|@#~€',
+ );
+ expect(['`', '+', '´', 'ç'].map((value) => altgr(layout, value)).join('')).toBe('[]{}');
+ });
+
+ it('has what a Latin American keyboard types with AltGr', () => {
+ const latam = createLayout('qwerty', 'MX');
+ expect(['1', '2', '4', "'"].map((value) => altgr(latam, value)).join('')).toBe('|@~\\');
+ expect(altgr(latam, '´')).toBe('^');
+ });
+
+ it('names the characters Spanish adds', () => {
+ expect(layout.names['¿']).toBeTruthy();
+ expect(layout.names['€']).toBe('euro');
+ });
+
+ it.each([
+ ['ES', 'es-ES'],
+ ['MX', 'es-MX'],
+ ['AR', 'es-AR'],
+ ['CO', 'es-CO'],
+ ['US', 'es-US'],
+ ['LATAM', 'es-419'],
+ ])('is valid for %s as %s with the euro on AltGr', (region, id) => {
+ const built = createLayout('qwerty', region);
+ expect(validateLayout(built)).toEqual([]);
+ expect(built.id).toBe(id);
+ expect(altgr(built, '5')).toBe('€');
+ });
+
+ it('has the keys of a Latin American keyboard there, and of a Spanish one in Spain', () => {
+ expect(layout.layers.letters[0].at(0).value).toBe('º');
+ expect(createLayout('qwerty', 'MX').layers.letters[0].at(0).value).toBe('|');
+ expect(shifted(createLayout('qwerty', 'MX'), '7')).toBe('/');
+ });
+
+ it('has a tall Enter beside rows 2 and 3', () => {
+ const [, row2, row3] = layout.layers.letters;
+ expect(row2.at(-1)).toMatchObject({ type: 'enter', rowSpan: 2 });
+ expect(row3.some((key) => key.type === 'enter')).toBe(false);
+ });
+
+ it('has shift and caps lock keys on the letters layer', () => {
+ const types = layout.layers.letters.flat().map((key) => key.type);
+ expect(types).toContain('shift');
+ expect(types).toContain('caps');
+ });
+
+ it('ends with an AltGr key, the space bar and a hide key', () => {
+ const last = layout.layers.letters.at(-1);
+ expect(last.at(0).type).toBe('altgr');
+ expect(last.map((key) => key.type)).toContain('space');
+ expect(last.at(-1).type).toBe('close');
+ });
+
+ it('uses the decimal sign of the region on the number pad', () => {
+ expect(layout.decimal).toBe(',');
+ expect(createLayout('qwerty', 'MX').decimal).toBe('.');
+ expect(createLayout('qwerty', 'AR').decimal).toBe(',');
+ });
+
+ it('rejects an unknown region', () => {
+ expect(() => createLayout('qwerty', 'FR')).toThrow(/Unknown region "FR"/);
+ });
+
+ it('rejects an unknown arrangement', () => {
+ expect(() => createLayout('dvorak')).toThrow(/Unknown arrangement "dvorak"/);
+ });
+});
diff --git a/packages/saccade-es/tsconfig.build.json b/packages/saccade-es/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-es/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/saccade-es/vite.config.js b/packages/saccade-es/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-es/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/packages/saccade-fr/README.md b/packages/saccade-fr/README.md
new file mode 100644
index 0000000..746de94
--- /dev/null
+++ b/packages/saccade-fr/README.md
@@ -0,0 +1,31 @@
+# @coldsofttech/saccade-fr
+
+The French keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), laid out like a physical keyboard of France, Belgium, Canada or Switzerland: a number row with its symbols (on a French keyboard the digits need Shift), the accented letters é è ç à ù on their own keys, Caps Lock, two Shift keys and a tall Enter. The AltGr key types the characters a French keyboard has under AltGr (@ # { [ | ` \ ^ ] } ~ €). It also types the circumflex and diaeresis letters (â ê î ô û ë ï ü ÿ), œ, æ and the guillemets « », which a real keyboard types with a dead key. Letters can be in the order of the region (the default) or in alphabetical order.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-fr
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('fr', () => import('@coldsofttech/saccade-fr'));
+
+ ;
+```
+
+## Other countries and alphabetical order
+
+```jsx
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { createLayout } from '@coldsofttech/saccade-fr';
+
+ ;
+ ;
+```
+
+`createLayout(type, region)` takes the letter order, `'azerty'` (default, the order of the region: AZERTY, or QWERTY in Canada and QWERTZ in Switzerland) or `'alphabetical'`, and the country: `'FR'` (default), `'BE'`, `'CA'` or `'CH'`.
+
+The country sets the language tag (`fr-CA`), the physical keys, what AltGr types and the decimal sign of the number pad (`,` except in Switzerland).
diff --git a/packages/saccade-fr/package.json b/packages/saccade-fr/package.json
new file mode 100644
index 0000000..0410b17
--- /dev/null
+++ b/packages/saccade-fr/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-fr",
+ "version": "0.1.0",
+ "description": "French keyboard layout for @coldsofttech/saccade-keyboards, in the order of its region or alphabetical.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-fr"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-fr/src/index.js b/packages/saccade-fr/src/index.js
new file mode 100644
index 0000000..8153c21
--- /dev/null
+++ b/packages/saccade-fr/src/index.js
@@ -0,0 +1,238 @@
+import { altGrKey, keys, physicalRows } from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+
+/**
+ * How the letters are arranged: `azerty` as on the keyboard of the region (QWERTY in Canada and
+ * QWERTZ in Switzerland), or `alphabetical` from a to z. Every other key stays where it is on the
+ * physical keyboard.
+ *
+ * @typedef {'azerty' | 'alphabetical'} Arrangement
+ */
+
+/**
+ * The country the keyboard is for: `FR` (France), `BE` (Belgium), `CA` (Canada) or `CH`
+ * (Switzerland).
+ *
+ * @typedef {'FR' | 'BE' | 'CA' | 'CH'} Region
+ */
+
+/** The letters of the three rows as each region has them. */
+const AZERTY = ['azertyuiop', 'qsdfghjklm', 'wxcvbn'];
+const QWERTY = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
+const QWERTZ = ['qwertzuiop', 'asdfghjkl', 'yxcvbnm'];
+
+/** The letters from a to z on the same keys, by how many letters the second row has. */
+const ALPHABETICAL = new Map([
+ [10, ['abcdefghij', 'klmnopqrst', 'uvwxyz']],
+ [9, ['abcdefghij', 'klmnopqrs', 'tuvwxyz']],
+]);
+
+/**
+ * The accented letters, the ligatures and the guillemets. A French keyboard types the circumflex
+ * and diaeresis vowels with a dead key (the accent, then the vowel), which this keyboard does not
+ * have, so AltGr types them in one press, on the vowel or on a key beside it. Keys are named by
+ * what they type.
+ */
+const ACCENTS = {
+ a: 'â',
+ z: 'ê',
+ r: 'ë',
+ y: 'ÿ',
+ u: 'û',
+ i: 'î',
+ o: 'ô',
+ p: 'œ',
+ q: 'æ',
+ j: 'ü',
+ k: 'ï',
+ w: '«',
+ x: '»',
+};
+
+/** What caps lock types on the keys of the accented letters, which have a digit with shift. */
+const CAPITALS = { é: 'É', è: 'È', ç: 'Ç', à: 'À', ù: 'Ù' };
+
+/** The keys of a French keyboard: the accented letters are on the number row, which needs shift. */
+const FRANCE = {
+ iso: true,
+ letters: AZERTY,
+ digits: ['²&é"\'(-è_çà)=', ' 1234567890°+'],
+ end2: ['^$', '¨£'],
+ end3: ['ù*', '%µ'],
+ extra4: ['<', '>'],
+ end4: [',;:!', '?./§'],
+ capitals: CAPITALS,
+ altgr: {
+ ...ACCENTS,
+ é: '~',
+ '"': '#',
+ "'": '{',
+ '(': '[',
+ '-': '|',
+ è: '`',
+ _: '\\',
+ ç: '^',
+ à: '@',
+ ')': ']',
+ '=': '}',
+ $: '¤',
+ e: '€',
+ },
+};
+
+/** The keys of a Belgian keyboard, which is an AZERTY keyboard with other signs. */
+const BELGIUM = {
+ iso: true,
+ letters: AZERTY,
+ digits: ['²&é"\'(§è!çà)-', ' 1234567890°_'],
+ end2: ['^$', '¨*'],
+ end3: ['ùµ', '%£'],
+ extra4: ['<', '>'],
+ end4: [',;:=', '?./+'],
+ capitals: CAPITALS,
+ altgr: {
+ ...ACCENTS,
+ '&': '|',
+ é: '@',
+ '"': '#',
+ '(': '[',
+ '§': '^',
+ ç: '{',
+ à: '}',
+ '^': '[',
+ $: ']',
+ '<': '\\',
+ '=': '~',
+ e: '€',
+ },
+};
+
+/** The keys of a Canadian keyboard (Canadian Multilingual Standard), which has QWERTY letters. */
+const CANADA = {
+ iso: true,
+ letters: QWERTY,
+ digits: ['/1234567890-=', '\\!@#$%?&*()_+'],
+ end2: ['^ç', '¨Ç'],
+ end3: [';èà', ':ÈÀ'],
+ extra4: ['ù', 'Ù'],
+ end4: [',.é', '\'"É'],
+ altgr: {
+ ...ACCENTS,
+ 3: '£',
+ 4: '¢',
+ 5: '¤',
+ 6: '¬',
+ 7: '¦',
+ 8: '²',
+ 9: '³',
+ 0: '¼',
+ '-': '½',
+ '=': '¾',
+ e: '€',
+ },
+};
+
+/** The keys of a Swiss keyboard, which has QWERTZ letters and the è, é and à on the right. */
+const SWITZERLAND = {
+ iso: true,
+ letters: QWERTZ,
+ digits: ["§1234567890'^", '°+"*ç%&/()=?`'],
+ end2: ['è¨', 'ü!'],
+ end3: ['éà$', 'öä£'],
+ extra4: ['<', '>'],
+ end4: [',.-', ';:_'],
+ altgr: {
+ ...ACCENTS,
+ 1: '¦',
+ 2: '@',
+ 3: '#',
+ 4: '°',
+ 5: '§',
+ 6: '¬',
+ 7: '|',
+ 8: '¢',
+ "'": '´',
+ '^': '~',
+ è: '[',
+ '¨': ']',
+ à: '{',
+ $: '}',
+ '<': '\\',
+ e: '€',
+ },
+};
+
+/** What differs by country: the name, the physical keys and the decimal sign of the number pad. */
+const REGIONS = new Map([
+ ['FR', { name: 'French (FR)', keyboard: FRANCE, decimal: ',' }],
+ ['BE', { name: 'French (BE)', keyboard: BELGIUM, decimal: ',' }],
+ ['CA', { name: 'French (CA)', keyboard: CANADA, decimal: ',' }],
+ ['CH', { name: 'French (CH)', keyboard: SWITZERLAND, decimal: '.' }],
+]);
+
+/** What a screen reader says for the characters French adds. */
+const NAMES = {
+ '²': 'exposant deux',
+ '³': 'exposant trois',
+ '°': 'degré',
+ '§': 'paragraphe',
+ µ: 'micro',
+ '£': 'livre',
+ '¤': 'symbole monétaire',
+ '€': 'euro',
+ '«': 'guillemet ouvrant',
+ '»': 'guillemet fermant',
+ '^': 'accent circonflexe',
+ '¨': 'tréma',
+ '`': 'accent grave',
+ '´': 'accent aigu',
+ '~': 'tilde',
+};
+
+/**
+ * The French layout in the given arrangement, for a country.
+ *
+ * @param {Arrangement} [type] The arrangement of the letters.
+ * @param {Region} [region] The country, which sets the physical keys.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'azerty', region = 'FR') {
+ const country = REGIONS.get(region);
+ if (!country) {
+ throw new Error(
+ `Unknown region "${region}". Use ${[...REGIONS.keys()].map((name) => `"${name}"`).join(', ')}.`,
+ );
+ }
+ if (type !== 'azerty' && type !== 'alphabetical') {
+ throw new Error(`Unknown arrangement "${type}". Use "azerty" or "alphabetical".`);
+ }
+ const { letters: own, ...keyboard } = country.keyboard;
+ const letters = type === 'azerty' ? own : (ALPHABETICAL.get(own.at(1)?.length ?? 0) ?? own);
+ return {
+ id: `fr-${region}`,
+ name: country.name,
+ dir: 'ltr',
+ decimal: country.decimal,
+ names: NAMES,
+ layers: {
+ letters: [
+ ...physicalRows({ ...keyboard, letters }),
+ [
+ { ...altGrKey, width: 1.5 },
+ ...keys('«»'),
+ { type: 'space', width: 5 },
+ ...keys('?!'),
+ { type: 'close', width: 1.5 },
+ ],
+ ],
+ },
+ };
+}
+
+/**
+ * The French layout for France in AZERTY order, which is what `language="fr"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-fr/src/index.test.js b/packages/saccade-fr/src/index.test.js
new file mode 100644
index 0000000..34e02a0
--- /dev/null
+++ b/packages/saccade-fr/src/index.test.js
@@ -0,0 +1,101 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
+
+const flat = (l) => l.layers.letters.flat();
+const lettersOf = (l) =>
+ flat(l)
+ .filter((key) => key.type === 'text' && ALPHABET.includes(key.value))
+ .map((key) => key.value);
+const find = (l, value) => flat(l).find((key) => key.value === value);
+const altgrOf = (l, values) => [...values].map((value) => find(l, value).altgr).join('');
+
+describe('saccade-fr', () => {
+ it('is a valid AZERTY layout for France by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('fr-FR');
+ expect(lettersOf(layout).slice(0, 10).join('')).toBe('azertyuiop');
+ });
+
+ it.each(['FR', 'BE', 'CA', 'CH'])('is valid for %s with all 26 letters once', (region) => {
+ for (const type of ['azerty', 'alphabetical']) {
+ const built = createLayout(type, region);
+ expect(validateLayout(built)).toEqual([]);
+ expect(built.id).toBe(`fr-${region}`);
+ expect(lettersOf(built).sort().join('')).toBe(ALPHABET);
+ }
+ });
+
+ it('has the letters of each region: AZERTY, QWERTY in Canada and QWERTZ in Switzerland', () => {
+ expect(lettersOf(createLayout('azerty', 'BE')).slice(0, 10).join('')).toBe('azertyuiop');
+ expect(lettersOf(createLayout('azerty', 'CA')).slice(0, 10).join('')).toBe('qwertyuiop');
+ expect(lettersOf(createLayout('azerty', 'CH')).slice(0, 10).join('')).toBe('qwertzuiop');
+ });
+
+ it('puts the letters in order when alphabetical', () => {
+ for (const region of ['FR', 'CA', 'CH']) {
+ expect(lettersOf(createLayout('alphabetical', region)).join('')).toBe(ALPHABET);
+ }
+ });
+
+ it('has the accented letters on the number row, with digits on shift', () => {
+ const [row1] = layout.layers.letters;
+ expect(row1.map((key) => key.value ?? key.type).join('')).toBe('²&é"\'(-è_çà)=backspace');
+ expect(find(layout, 'é').shift).toBe('2');
+ expect(find(layout, '&').shift).toBe('1');
+ expect(find(layout, '²').shift).toBeUndefined();
+ });
+
+ it('types capital accented letters with caps lock', () => {
+ const capitals = ['é', 'è', 'ç', 'à', 'ù'].map((value) => find(layout, value).capital);
+ expect(capitals.join('')).toBe('ÉÈÇÀÙ');
+ });
+
+ it('has what a French keyboard types with AltGr, and the euro', () => {
+ expect(altgrOf(layout, ['é', '"', "'", '(', '-', 'è', 'ç', 'à'])).toBe('~#{[|`^@');
+ expect(find(layout, 'e').altgr).toBe('€');
+ });
+
+ it('has the circumflex and diaeresis vowels, the ligatures and the guillemets on AltGr', () => {
+ expect(altgrOf(layout, 'azruiokjypqwx')).toBe('âêëûîôïüÿœæ«»');
+ expect(find(createLayout('alphabetical'), 'a').altgr).toBe('â');
+ });
+
+ it('has the keys of each region', () => {
+ expect(find(createLayout('azerty', 'BE'), '§').shift).toBe('6');
+ expect(createLayout('azerty', 'CA').layers.letters[0][0].value).toBe('/');
+ expect(find(createLayout('azerty', 'CH'), 'è').shift).toBe('ü');
+ expect(createLayout('azerty', 'CH').layers.letters[0][0].value).toBe('§');
+ });
+
+ it('has a tall Enter, shift on both sides and an AltGr key', () => {
+ const rows = layout.layers.letters;
+ expect(rows[1].at(-1)).toMatchObject({ type: 'enter', rowSpan: 2 });
+ expect(rows[3].at(0).type).toBe('shift');
+ expect(rows[3].at(-1).type).toBe('shift');
+ expect(rows.at(-1).at(0).type).toBe('altgr');
+ expect(rows.at(-1).at(-1).type).toBe('close');
+ expect(Object.keys(layout.layers)).toEqual(['letters']);
+ });
+
+ it('uses the decimal sign of the region on the number pad', () => {
+ expect(layout.decimal).toBe(',');
+ expect(createLayout('azerty', 'CA').decimal).toBe(',');
+ expect(createLayout('azerty', 'CH').decimal).toBe('.');
+ });
+
+ it('names the characters French adds', () => {
+ expect(layout.names['€']).toBe('euro');
+ expect(layout.names['«']).toBeTruthy();
+ });
+
+ it('rejects an unknown region', () => {
+ expect(() => createLayout('azerty', 'ES')).toThrow(/Unknown region "ES"/);
+ });
+
+ it('rejects an unknown arrangement', () => {
+ expect(() => createLayout('qwerty')).toThrow(/Unknown arrangement "qwerty"/);
+ });
+});
diff --git a/packages/saccade-fr/tsconfig.build.json b/packages/saccade-fr/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-fr/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/saccade-fr/vite.config.js b/packages/saccade-fr/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-fr/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/packages/saccade-hi/README.md b/packages/saccade-hi/README.md
new file mode 100644
index 0000000..3e83dd3
--- /dev/null
+++ b/packages/saccade-hi/README.md
@@ -0,0 +1,32 @@
+# @coldsofttech/saccade-hi
+
+The Hindi keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), in two layouts: **InScript**, the standard keyboard of India (consonants on the right hand, vowels on the left, Shift for the aspirated letters and conjuncts such as ज्ञ and क्ष), and **phonetic**, where each key types the Hindi letter that sounds like the Latin letter on it. The phonetic keyboard has a second layer with the vowel signs (ा ि ी ु ू ृ े ै ो ौ), anusvara, chandrabindu, visarga, halant and nukta.
+
+The number row types Latin digits (0-9) with the Devanagari digits (०-९) on AltGr, or the other way round.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-hi
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('hi', () => import('@coldsofttech/saccade-hi'));
+
+ ;
+```
+
+## Phonetic and Devanagari digits
+
+```jsx
+import { createLayout } from '@coldsofttech/saccade-hi';
+
+ ;
+ ;
+```
+
+`createLayout(type, digits)` takes the keyboard, `'inscript'` (default) or `'phonetic'`, and the digits of the number row, `'latin'` (default) or `'devanagari'`.
+
+The ₹ sign is on the currency row of the `currencypad` option.
diff --git a/packages/saccade-hi/package.json b/packages/saccade-hi/package.json
new file mode 100644
index 0000000..cfe90e9
--- /dev/null
+++ b/packages/saccade-hi/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-hi",
+ "version": "0.1.0",
+ "description": "Hindi keyboard layouts (InScript and phonetic) for @coldsofttech/saccade-keyboards.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-hi"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-hi/src/index.js b/packages/saccade-hi/src/index.js
new file mode 100644
index 0000000..65d7d6d
--- /dev/null
+++ b/packages/saccade-hi/src/index.js
@@ -0,0 +1,233 @@
+import {
+ altGrKey,
+ backspaceKey,
+ bottomRow,
+ capsKey,
+ keys,
+ shiftKey,
+} from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+/** @typedef {Layout['layers'][string][number][number]} Key */
+/** @typedef {Extract} TextKey */
+/** @typedef {Extract} LayerKey */
+
+/**
+ * Which Hindi keyboard: `inscript`, the standard layout of India (consonants on the right hand,
+ * vowels on the left, shift for the aspirated ones), or `phonetic`, where each key types the Hindi
+ * letter that sounds like the Latin one on a QWERTY keyboard, with the vowel signs on their own
+ * layer.
+ *
+ * @typedef {'inscript' | 'phonetic'} Arrangement
+ */
+
+/**
+ * Which digits the number row types: `latin` (0-9) with the Devanagari digits (०-९) on AltGr, or
+ * `devanagari` with the Latin digits on AltGr.
+ *
+ * @typedef {'latin' | 'devanagari'} Digits
+ */
+
+const LATIN = '0123456789';
+const DEVANAGARI = '०१२३४५६७८९';
+
+/**
+ * A text key, with what it types with shift and after AltGr when it has them.
+ *
+ * @param {string} value
+ * @param {string} [shift]
+ * @param {string} [altgr]
+ * @returns {TextKey}
+ */
+const pair = (value, shift, altgr) => ({
+ type: 'text',
+ value,
+ ...(shift && { shift }),
+ ...(altgr && { altgr }),
+});
+
+/**
+ * Keys from space separated characters (a letter may be several characters, such as ज्ञ) and the
+ * ones they type with shift, in the same order.
+ *
+ * @param {string} normal
+ * @param {string} [shifted]
+ * @returns {TextKey[]}
+ */
+const row = (normal, shifted = '') => {
+ const upper = shifted.split(' ');
+ return normal.split(' ').map((value, i) => pair(value, upper.at(i)));
+};
+
+/**
+ * The key of a digit: the digit in the chosen script, the other script on AltGr.
+ *
+ * @param {Digits} digits
+ * @param {number} n
+ * @param {string} shift
+ * @returns {TextKey}
+ */
+const digitKey = (digits, n, shift) =>
+ digits === 'devanagari'
+ ? pair(DEVANAGARI.at(n) ?? '', shift, LATIN.at(n))
+ : pair(LATIN.at(n) ?? '', shift, DEVANAGARI.at(n));
+
+/**
+ * The four rows of an ANSI keyboard from the keys of each row.
+ *
+ * @param {TextKey[][]} rows
+ * @returns {Key[][]}
+ */
+const assemble = ([top, second, third, bottom]) => [
+ [...top, { ...backspaceKey, width: 2 }],
+ [...second.slice(0, -1), { .../** @type {TextKey} */ (second.at(-1)), width: 1.5 }],
+ [{ ...capsKey, width: 1.75 }, ...third, { type: 'enter', width: 2.25 }],
+ [{ ...shiftKey, width: 2.25 }, ...bottom, { ...shiftKey, width: 2.75 }],
+];
+
+/**
+ * The InScript keyboard: the signs sit on the number row, with Shift for the less common ones.
+ *
+ * @param {Digits} digits
+ * @returns {Key[][]}
+ */
+const inscript = (digits) =>
+ assemble([
+ [
+ pair('ॊ', 'ऒ'),
+ digitKey(digits, 1, 'ऍ'),
+ digitKey(digits, 2, 'ॅ'),
+ digitKey(digits, 3, '्र'),
+ digitKey(digits, 4, 'र्'),
+ digitKey(digits, 5, 'ज्ञ'),
+ digitKey(digits, 6, 'त्र'),
+ digitKey(digits, 7, 'क्ष'),
+ digitKey(digits, 8, 'श्र'),
+ digitKey(digits, 9, '('),
+ digitKey(digits, 0, ')'),
+ pair('-', 'ः'),
+ pair('ृ', 'ऋ'),
+ ],
+ row('ौ ै ा ी ू ब ह ग द ज ड ़ ॉ', 'औ ऐ आ ई ऊ भ ङ घ ध झ ढ ञ ऑ'),
+ row('ो े ् ि ु प र क त च ट', 'ओ ए अ इ उ फ ऱ ख थ छ ठ'),
+ row('ॆ ं म न व ल स , . य', 'ऎ ँ ण ऩ ऴ ळ श ष । य़'),
+ ]);
+
+/**
+ * The phonetic keyboard: the Hindi letter that sounds like the Latin one, shift for its pair.
+ *
+ * @param {Digits} digits
+ * @returns {Key[][]}
+ */
+const phonetic = (digits) =>
+ assemble([
+ [
+ pair('`', '~'),
+ digitKey(digits, 1, '!'),
+ digitKey(digits, 2, '@'),
+ digitKey(digits, 3, '#'),
+ digitKey(digits, 4, '$'),
+ digitKey(digits, 5, '%'),
+ digitKey(digits, 6, '^'),
+ digitKey(digits, 7, '&'),
+ digitKey(digits, 8, '*'),
+ digitKey(digits, 9, '('),
+ digitKey(digits, 0, ')'),
+ pair('-', '_'),
+ pair('=', '+'),
+ ],
+ [...row('ट ड ए र त य उ इ ओ प', 'ठ ढ ऐ ऋ थ ञ ऊ ई औ फ'), ...keys('[]\\', '{}|')],
+ [...row('अ स द फ़ ग ह ज क ल', 'आ श ध ़ घ ः झ ख ळ'), ...keys(";'", ':"')],
+ [...row('ज़ क्ष च व ब न म', 'ष ज्ञ छ ँ भ ण ं'), pair(',', '॥'), pair('.', '।'), pair('/', '?')],
+ ]);
+
+/**
+ * The vowel signs, the other signs and the conjuncts, which the phonetic letters do not have.
+ *
+ * @param {LayerKey} back
+ * @returns {Key[][]}
+ */
+const signs = (back) => [
+ row('ा ि ी ु ू ृ'),
+ row('े ै ो ौ ॅ ॉ'),
+ row('ं ँ ः ् ़ ऽ'),
+ [...row('। ॥ ॐ ङ त्र श्र'), backspaceKey],
+ bottomRow(back),
+];
+
+/** @type {LayerKey} */
+const LETTERS_KEY = { type: 'layer', to: 'signs', label: 'मात्रा', name: 'Vowel signs' };
+/** @type {LayerKey} */
+const BACK_KEY = { type: 'layer', to: 'letters', label: 'अक्षर', name: 'Letters' };
+
+/** What a screen reader says for the signs, which have no sound of their own. */
+const NAMES = {
+ 'ा': 'आ की मात्रा',
+ 'ि': 'इ की मात्रा',
+ 'ी': 'ई की मात्रा',
+ 'ु': 'उ की मात्रा',
+ 'ू': 'ऊ की मात्रा',
+ 'ृ': 'ऋ की मात्रा',
+ 'े': 'ए की मात्रा',
+ 'ै': 'ऐ की मात्रा',
+ 'ो': 'ओ की मात्रा',
+ 'ौ': 'औ की मात्रा',
+ 'ॅ': 'चंद्र',
+ 'ॉ': 'ऑ की मात्रा',
+ 'ॊ': 'ओ की छोटी मात्रा',
+ 'ॆ': 'ए की छोटी मात्रा',
+ 'ं': 'अनुस्वार',
+ 'ँ': 'चंद्रबिंदु',
+ 'ः': 'विसर्ग',
+ '्': 'हलंत',
+ '़': 'नुक़्ता',
+ ऽ: 'अवग्रह',
+ ॐ: 'ओम',
+ '।': 'पूर्ण विराम',
+ '॥': 'दोहरा पूर्ण विराम',
+ '₹': 'रुपया',
+};
+
+/**
+ * The Hindi layout in the given arrangement.
+ *
+ * @param {Arrangement} [type] The keyboard.
+ * @param {Digits} [digits] The digits of the number row.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'inscript', digits = 'latin') {
+ if (type !== 'inscript' && type !== 'phonetic') {
+ throw new Error(`Unknown arrangement "${type}". Use "inscript" or "phonetic".`);
+ }
+ if (digits !== 'latin' && digits !== 'devanagari') {
+ throw new Error(`Unknown digits "${digits}". Use "latin" or "devanagari".`);
+ }
+ const isInscript = type === 'inscript';
+ /** @type {Key[]} */
+ const bottom = [
+ { ...altGrKey, width: 1.5 },
+ ...(isInscript ? [] : [{ ...LETTERS_KEY, width: 1.5 }]),
+ ...keys('?!'),
+ { type: 'space', width: isInscript ? 5 : 3.5 },
+ ...keys(isInscript ? ':;' : ':॰'),
+ { type: 'close', width: 1.5 },
+ ];
+ return {
+ id: 'hi-IN',
+ name: isInscript ? 'Hindi (InScript)' : 'Hindi (phonetic)',
+ dir: 'ltr',
+ decimal: '.',
+ names: NAMES,
+ layers: {
+ letters: [...(isInscript ? inscript(digits) : phonetic(digits)), bottom],
+ ...(isInscript ? {} : { signs: signs(BACK_KEY) }),
+ },
+ };
+}
+
+/**
+ * The Hindi InScript layout with Latin digits, which is what `language="hi"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-hi/src/index.test.js b/packages/saccade-hi/src/index.test.js
new file mode 100644
index 0000000..0f83675
--- /dev/null
+++ b/packages/saccade-hi/src/index.test.js
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const flat = (l, name = 'letters') => new Map(Object.entries(l.layers)).get(name).flat();
+const find = (l, value, name) => flat(l, name).find((key) => key.value === value);
+const typed = (l) =>
+ flat(l)
+ .filter((key) => key.type === 'text')
+ .flatMap((key) => [key.value, key.shift, key.altgr]);
+
+describe('saccade-hi', () => {
+ it('is a valid InScript layout with Latin digits by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('hi-IN');
+ expect(layout.name).toBe('Hindi (InScript)');
+ expect(find(layout, '1').altgr).toBe('१');
+ });
+
+ it.each(['inscript', 'phonetic'])('is valid as %s with either digits', (type) => {
+ for (const digits of ['latin', 'devanagari']) {
+ expect(validateLayout(createLayout(type, digits))).toEqual([]);
+ }
+ });
+
+ it('has the InScript consonants and vowels where the standard has them', () => {
+ expect(find(layout, 'ब').shift).toBe('भ');
+ expect(find(layout, 'ा').shift).toBe('आ');
+ expect(find(layout, 'क').shift).toBe('ख');
+ expect(find(layout, '्')).toBeDefined();
+ expect(find(layout, '5').shift).toBe('ज्ञ');
+ });
+
+ it('types every Devanagari letter and sign of InScript once as its own character', () => {
+ const all = typed(layout).filter((value) => /^[ऀ-ॿ]$/u.test(value ?? ''));
+ expect(all).toContain('ङ');
+ expect(all).toContain('ऋ');
+ expect(all).toContain('ं');
+ expect(all).toContain('ँ');
+ expect(all).toContain('।');
+ });
+
+ it('swaps the digits with the other script on AltGr', () => {
+ const dev = createLayout('inscript', 'devanagari');
+ expect(find(dev, '१').altgr).toBe('1');
+ expect(find(dev, '१').shift).toBe('ऍ');
+ expect(find(dev, '1')).toBeUndefined();
+ });
+
+ it('has a phonetic layer with the vowel signs behind a layer key', () => {
+ const phonetic = createLayout('phonetic');
+ expect(phonetic.name).toBe('Hindi (phonetic)');
+ expect(Object.keys(phonetic.layers)).toEqual(['letters', 'signs']);
+ expect(flat(phonetic).some((key) => key.type === 'layer' && key.to === 'signs')).toBe(true);
+ expect(find(phonetic, 'ा', 'signs')).toBeDefined();
+ expect(find(phonetic, '्', 'signs')).toBeDefined();
+ expect(
+ flat(phonetic, 'signs').some((key) => key.type === 'layer' && key.to === 'letters'),
+ ).toBe(true);
+ expect(find(phonetic, 'क').shift).toBe('ख');
+ expect(find(phonetic, 'ब').shift).toBe('भ');
+ });
+
+ it('names the signs for a screen reader', () => {
+ expect(layout.names['्']).toBe('हलंत');
+ });
+
+ it('rejects an unknown arrangement or digits', () => {
+ expect(() => createLayout('remington')).toThrow(/Unknown arrangement/);
+ expect(() => createLayout('inscript', 'roman')).toThrow(/Unknown digits/);
+ });
+});
diff --git a/packages/saccade-hi/tsconfig.build.json b/packages/saccade-hi/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-hi/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/saccade-hi/vite.config.js b/packages/saccade-hi/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-hi/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/packages/saccade-keyboards/README.md b/packages/saccade-keyboards/README.md
new file mode 100644
index 0000000..d111195
--- /dev/null
+++ b/packages/saccade-keyboards/README.md
@@ -0,0 +1,152 @@
+# @coldsofttech/saccade-keyboards
+
+On-screen keyboards for React. Pick a language, or build your own layout from data. React and `react-dom` 18 or 19 must be installed in your app.
+
+```bash
+npm install @coldsofttech/saccade-keyboards
+```
+
+## Use it
+
+The keyboard never edits a field. It tells you which key was pressed, and you type into your own field. Pressing a key keeps the focus (and the caret) in that field.
+
+```jsx
+import { useRef } from 'react';
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+import { applyKey } from '@coldsofttech/saccade-keyboards/core';
+import '@coldsofttech/saccade-keyboards/style.css';
+
+registerLanguage('en', () => import('@coldsofttech/saccade-en'));
+
+function Field() {
+ const input = useRef(null);
+
+ const onKey = (key) => {
+ const { selectionStart, selectionEnd, value } = input.current;
+ const next = applyKey(value, { start: selectionStart, end: selectionEnd }, key);
+ input.current.value = next.text;
+ input.current.setSelectionRange(next.selection.start, next.selection.end);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+`language` needs that language's package, for example `@coldsofttech/saccade-en` (QWERTY by default; use its `createLayout('alphabetical')` with the `layout` prop for alphabetical letters), and a `registerLanguage` call for it. If the package is missing, the keyboard says which one to install.
+
+## Props
+
+| Prop | Meaning |
+| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `language` | The language code, such as `en`. Loaded the first time it is needed. |
+| `layout` | Your own layout instead of `language` (see the layout format in the repository's docs). |
+| `onKey` | Called with `{ type: 'text', value }`, `{ type: 'backspace' }` or `{ type: 'enter' }`. |
+| `onClose` | Called when the hide key is pressed. |
+| `theme` | `'light'` (default), `'dark'`, or colours: `background`, `key`, `keyText`, `border`, `accent`, `accentText`. |
+| `label` | The name of the keyboard for a screen reader. |
+| `font` | The font of the keys: `'native'` (default, the device's own font), `'sans'`, `'serif'`, `'mono'`. |
+| `numpad` | Show a number pad beside the keys (false): Num Lock and the operators, the digits, a tall plus and Enter. Num Lock starts on; while it is off the digits and the decimal key do nothing. |
+| `currencypad` | Add a currency key before the space key (false). It shows or hides a row of currency signs above the keys; the rest of the layout stays as it is. |
+| `fontSize` | The size of the letters on the keys, in pixels (16). |
+| `fallback` | What to show while a language loads. |
+
+## Styling
+
+Import `@coldsofttech/saccade-keyboards/style.css` once, then change the look with props.
+
+**Light or dark**
+
+```jsx
+
+```
+
+**Your own colours.** Pass any of the colours; the ones you leave out keep the light theme's colour.
+
+```jsx
+
+```
+
+| Colour | Where it shows |
+| ------------ | --------------------------------------------- |
+| `background` | Behind the keys. |
+| `key` | A key. |
+| `keyText` | The letters on a key. |
+| `border` | The edge of a key. |
+| `accent` | A key that is on, such as Shift or Caps Lock. |
+| `accentText` | The letters on a key that is on. |
+
+**Font and size.** `font` takes `'native'`, `'sans'`, `'serif'`, `'mono'`, or any CSS font list. `fontSize` is in pixels.
+
+```jsx
+
+```
+
+**Width and position.** The keyboard fills the width of the space it is placed in, so to set its width or position, put it in an element of your own and style that.
+
+```jsx
+
+
+;
+
+{
+ /* or responsive, centred */
+}
+
+
+;
+```
+
+**Minimum width.** Narrower than this the keys are too small for their letters and would overlap, so the keyboard does not shrink below it (at the default `fontSize` of 16):
+
+| Keyboard | Minimum width |
+| ------------- | ------------- |
+| Keys only | 430 px |
+| With `numpad` | 600 px |
+
+In a narrower space it keeps this width and runs past the edge, so give it a wide enough element (or let the element scroll sideways on small screens).
+
+**Narrow keyboards.** At 800 px wide or less the keys are small, with or without the number pad, so each key shows only the character it types. The Shift symbols (like `!` above the `1`) and the small AltGr characters appear when you press Shift or AltGr. Wider than 800 px, every key shows all of its characters.
+
+## Loading languages
+
+Each language is its own package, and `registerLanguage` takes a function that imports it. The package is only downloaded when the keyboard first needs that language, so your app does not carry the languages it does not use. Pass `fallback` to show something while it loads.
+
+```jsx
+registerLanguage('en', () => import('@coldsofttech/saccade-en'));
+registerLanguage('fr', () => import('@coldsofttech/saccade-fr'));
+
+Loading keyboard…} />;
+```
+
+## Build your own
+
+`@coldsofttech/saccade-keyboards/core` has what you need without the language loader:
+
+- `Keyboard`: the same keyboard for a `layout` object.
+- `useKeyboard`: the state and behaviour without markup, to draw the keys yourself.
+- `applyKey`: applies a key press to text and a selection.
+- `validateLayout`: lists what is wrong with a layout.
+- `Layout`: the type of a layout, for packages that provide one.
+- `keys`, `cased`, `physicalRows`, `numpadRows`, `bottomRow`, `symbolsLayer`, `shiftKey`, `capsKey`, `altGrKey`, `backspaceKey`, `PUNCTUATION_NAMES`: helpers to write layouts.
+
+`registerLanguage(id, loader)`, `languages()` and `loadLayout(id)` from the main entry let you add a language by its code.
+
+## License
+
+MIT
diff --git a/packages/example-react-package/package.json b/packages/saccade-keyboards/package.json
similarity index 64%
rename from packages/example-react-package/package.json
rename to packages/saccade-keyboards/package.json
index 4e56029..6a08887 100644
--- a/packages/example-react-package/package.json
+++ b/packages/saccade-keyboards/package.json
@@ -1,7 +1,7 @@
{
- "name": "@scope/example-react-package",
+ "name": "@coldsofttech/saccade-keyboards",
"version": "0.1.0",
- "description": "Example React package. Replace this with your own description.",
+ "description": "On-screen keyboards for React. Pick a language, or build your own layout from data.",
"license": "MIT",
"type": "module",
"sideEffects": [
@@ -15,6 +15,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
+ "./core": {
+ "types": "./dist/core/index.d.ts",
+ "import": "./dist/core/index.js"
+ },
"./style.css": "./dist/style.css"
},
"files": [
@@ -29,12 +33,12 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/coldsofttech/react-SaccadeKeyboards.git",
- "directory": "packages/example-react-package"
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-keyboards"
},
- "homepage": "https://github.com/coldsofttech/react-SaccadeKeyboards#readme",
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
"bugs": {
- "url": "https://github.com/coldsofttech/react-SaccadeKeyboards/issues"
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
},
"scripts": {
"build": "vite build && npm run build:types",
diff --git a/packages/saccade-keyboards/src/Keyboard.jsx b/packages/saccade-keyboards/src/Keyboard.jsx
new file mode 100644
index 0000000..a39d93d
--- /dev/null
+++ b/packages/saccade-keyboards/src/Keyboard.jsx
@@ -0,0 +1,49 @@
+import { useEffect, useState } from 'react';
+import { Keyboard as CoreKeyboard } from './core/Keyboard.jsx';
+import { loadLayout } from './registry.js';
+
+/** @typedef {import('./core/layout.js').Layout} Layout */
+
+/**
+ * @typedef {Omit & {
+ * language?: string,
+ * layout?: import('./core/layout.js').Layout,
+ * fallback?: import('react').ReactNode,
+ * }} LanguageKeyboardProps
+ */
+
+/**
+ * An on-screen keyboard picked by language. The layout is loaded the first time it is needed, and
+ * a missing one says which package to install. Pass `layout` instead to use your own.
+ *
+ * @param {LanguageKeyboardProps} props Component props: `language` (a registered code), or
+ * `layout`; `fallback` shows while a layout loads; the rest go to the keyboard.
+ * @returns {import('react').ReactNode} The keyboard, the fallback, or the error.
+ */
+export function Keyboard({ language, layout, fallback = null, ...props }) {
+ const [result, setResult] = useState(
+ /** @type {{ language: string, layout: Layout | null, error: Error | null }} */ ({
+ language: '',
+ layout: null,
+ error: null,
+ }),
+ );
+
+ useEffect(() => {
+ if (layout || !language) return undefined;
+ let current = true;
+ loadLayout(language).then(
+ (loaded) => current && setResult({ language, layout: loaded, error: null }),
+ (error) => current && setResult({ language, layout: null, error }),
+ );
+ return () => {
+ current = false;
+ };
+ }, [language, layout]);
+
+ const loaded = result.language === language ? result : null;
+ const shown = layout ?? loaded?.layout;
+ if (shown) return ;
+ if (loaded?.error) return {loaded.error.message}
;
+ return fallback;
+}
diff --git a/packages/saccade-keyboards/src/Keyboard.test.jsx b/packages/saccade-keyboards/src/Keyboard.test.jsx
new file mode 100644
index 0000000..cb7fc8b
--- /dev/null
+++ b/packages/saccade-keyboards/src/Keyboard.test.jsx
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from 'vitest';
+import { axe } from 'vitest-axe';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Keyboard, languages, registerLanguage } from './index.js';
+
+const layout = {
+ id: 'en',
+ name: 'Test',
+ dir: 'ltr',
+ layers: { letters: [[{ type: 'text', value: 'a' }]] },
+};
+
+describe('Keyboard by language', () => {
+ it('loads the layout of a registered language', async () => {
+ registerLanguage('xx', async () => ({ layout }));
+ const onKey = vi.fn();
+ const { container } = render( );
+ await userEvent.click(await screen.findByRole('button', { name: 'a' }));
+ expect(onKey).toHaveBeenCalledWith({ type: 'text', value: 'a' });
+ expect(languages()).toContain('xx');
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it('shows the fallback while the layout loads', async () => {
+ registerLanguage('slow', () => new Promise(() => {}));
+ render(Loading} />);
+ expect(screen.getByText('Loading')).toBeInTheDocument();
+ });
+
+ it('says which package to install for an unknown language', async () => {
+ render( );
+ expect(await screen.findByRole('alert')).toHaveTextContent('@coldsofttech/saccade-zz');
+ });
+
+ it('refuses a layout that is not valid', async () => {
+ registerLanguage('bad', async () => ({ id: 'bad' }));
+ render( );
+ expect(await screen.findByRole('alert')).toHaveTextContent('not a valid layout');
+ });
+
+ it('uses a layout passed directly', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'a' })).toBeInTheDocument();
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/Keyboard.jsx b/packages/saccade-keyboards/src/core/Keyboard.jsx
new file mode 100644
index 0000000..ac2c6e4
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/Keyboard.jsx
@@ -0,0 +1,204 @@
+import styles from './Keyboard.module.css';
+import { placeKeys } from './grid.js';
+import { keys, numpadRows } from './layout.js';
+import { CURRENCY_SIGNS, useKeyboard } from './useKeyboard.js';
+
+/** @typedef {import('./layout.js').Layout} Layout */
+/** @typedef {import('./applyKey.js').KeyPress} KeyPress */
+
+/**
+ * Colours for a custom theme. Anything left out keeps the light theme's colour.
+ *
+ * @typedef {object} ThemeColors
+ * @property {string} [background] Behind the keys.
+ * @property {string} [key] A key.
+ * @property {string} [keyText] Text on a key.
+ * @property {string} [border] Edge of a key.
+ * @property {string} [accent] A key that is on, such as shift.
+ * @property {string} [accentText] Text on a key that is on.
+ */
+
+/**
+ * @typedef {object} KeyboardProps
+ * @property {Layout} layout The layout; the first layer is shown first.
+ * @property {(key: KeyPress) => void} onKey A key was pressed (the space key types a space).
+ * @property {() => void} [onClose] The hide key was pressed.
+ * @property {'light' | 'dark' | ThemeColors} [theme] Colours of the keyboard ('light').
+ * @property {string} [label] The name of the keyboard for a screen reader.
+ * @property {string} [font] The font of the keys: `native` (the device's own font), `sans`, `serif`,
+ * `mono`, or any CSS font-family list, such as `"Atkinson Hyperlegible", sans-serif` ('native').
+ * @property {boolean} [numpad] Show a number pad beside the keys (false). Its decimal key types the
+ * `decimal` of the layout.
+ * @property {boolean} [currencypad] Add a currency key before the space key (false). It shows or
+ * hides a row of currency signs above the keys.
+ * @property {number} [fontSize] The size of the letters on the keys, in pixels (16).
+ */
+
+/** The lighter character of a two-character key; a narrow keyboard hides it until it is typed. */
+const hint = `${styles.secondary} ${styles.hint}`;
+
+const TOKENS = new Map([
+ ['background', '--saccade-background'],
+ ['key', '--saccade-key'],
+ ['keyText', '--saccade-key-text'],
+ ['border', '--saccade-border'],
+ ['accent', '--saccade-accent'],
+ ['accentText', '--saccade-accent-text'],
+]);
+
+/** Fonts every device has, so nothing is bundled and no font licence is involved. */
+const FONTS = new Map([
+ ['native', 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'],
+ ['sans', 'ui-sans-serif, "Helvetica Neue", Arial, sans-serif'],
+ ['serif', 'ui-serif, Georgia, "Times New Roman", serif'],
+ ['mono', 'ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace'],
+]);
+
+/** @param {KeyboardProps['theme']} theme */
+function themeProps(theme) {
+ if (theme !== null && typeof theme === 'object') {
+ const entries = Object.entries(theme).flatMap(([name, value]) =>
+ TOKENS.has(name) ? [[TOKENS.get(name), value]] : [],
+ );
+ return { className: styles.root, style: Object.fromEntries(entries) };
+ }
+ return { className: theme === 'dark' ? `${styles.root} ${styles.dark}` : styles.root };
+}
+
+/**
+ * Whether a key has a second character of its own, like the ! above the 1, rather than just the
+ * capital of a letter.
+ *
+ * @param {import('./layout.js').Key} key
+ * @returns {key is import('./layout.js').TextKey}
+ */
+const hasSecondCharacter = (key) =>
+ key.type === 'text' && key.shift !== undefined && key.shift !== key.value.toUpperCase();
+
+/**
+ * @param {{ className: string, style?: Record }} props Theme props.
+ * @param {string} font The `font` prop.
+ */
+function withFont(props, font) {
+ return { ...props, style: { ...props.style, fontFamily: FONTS.get(font) ?? font } };
+}
+
+/**
+ * The rows with a currency key before each space key, which gives up the same width so the row
+ * keeps its length.
+ *
+ * @param {import('./layout.js').Key[][]} rows
+ * @returns {import('./layout.js').Key[][]}
+ */
+const withCurrencyKey = (rows) =>
+ rows.map((row) =>
+ row.flatMap((key) =>
+ key.type === 'space'
+ ? [
+ { type: 'currency', width: 1 },
+ { ...key, width: (key.width ?? 1) - 1 },
+ ]
+ : [key],
+ ),
+ );
+
+/**
+ * An on-screen keyboard for a layout. It edits nothing: it says which key was pressed, and
+ * whoever uses it types into its own field (see `applyKey`). Pressing a key keeps the focus where
+ * it was.
+ *
+ * @param {KeyboardProps} props Component props.
+ * @returns {import('react').JSX.Element} The keyboard.
+ */
+export function Keyboard({
+ layout,
+ onKey,
+ onClose,
+ theme = 'light',
+ label,
+ font = 'native',
+ numpad = false,
+ currencypad = false,
+ fontSize = 16,
+}) {
+ const { rows, shift, altGr, currencyOpen, press, describe } = useKeyboard({
+ layout,
+ onKey,
+ onClose,
+ });
+
+ /** @param {import('./grid.js').PlacedKey[][]} placed */
+ const keysOf = (placed) =>
+ placed.flat().map(({ key, column, columnSpan, row, rowSpan }, k) => {
+ const { label: text, caption, name, pressed, disabled } = describe(key);
+ return (
+
+ );
+ });
+
+ const appearance = withFont(themeProps(theme), font);
+
+ return (
+ // The keys sit where the physical keys are, left to right, whatever way the language reads.
+
+
+ {currencypad && currencyOpen && (
+
+ {keysOf(placeKeys([keys(CURRENCY_SIGNS)]))}
+
+ )}
+
+ {keysOf(placeKeys(currencypad ? withCurrencyKey(rows) : rows))}
+
+
+ {numpad && (
+
+ {keysOf(placeKeys(numpadRows(layout.decimal)))}
+
+ )}
+
+ );
+}
diff --git a/packages/saccade-keyboards/src/core/Keyboard.module.css b/packages/saccade-keyboards/src/core/Keyboard.module.css
new file mode 100644
index 0000000..b46b7c3
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/Keyboard.module.css
@@ -0,0 +1,123 @@
+.root {
+ --saccade-background: #f0f0f0;
+ --saccade-key: #ffffff;
+ --saccade-key-text: #242424;
+ --saccade-border: #d1d1d1;
+ --saccade-accent: #0f6cbd;
+ --saccade-accent-text: #ffffff;
+
+ display: flex;
+ gap: 10px;
+ padding: 5px;
+ box-sizing: border-box;
+ /* Narrower than this the keys are too small for their letters and overlap. */
+ min-width: 430px;
+ container-type: inline-size;
+ user-select: none;
+ touch-action: manipulation;
+ background: var(--saccade-background);
+ border-radius: 4px;
+}
+
+/* The number pad needs room of its own. */
+.wide {
+ min-width: 600px;
+}
+
+.keys,
+.numpad {
+ /* One grid of narrow columns; a key covers as many as its width needs (see grid.js). Spacing is
+ a margin on each key, because a column gap would be paid by every one of the columns. */
+ display: grid;
+ grid-template-columns: repeat(120, minmax(0, 1fr));
+}
+
+.main {
+ display: flex;
+ flex: 15 1 0;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 0;
+}
+
+.numpad {
+ flex: 4 1 0;
+ min-width: 0;
+}
+
+.dark {
+ --saccade-background: #292929;
+ --saccade-key: #3d3d3d;
+ --saccade-key-text: #ffffff;
+ --saccade-border: #666666;
+ --saccade-accent: #479ef5;
+ --saccade-accent-text: #000000;
+}
+
+.key {
+ position: relative;
+ min-width: 0;
+ margin: 3px;
+ min-height: 44px;
+ padding: 0 2px;
+ /* A long label (such as a layer name) is cut short rather than running over its neighbours. */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font: inherit;
+ cursor: pointer;
+ color: var(--saccade-key-text);
+ background: var(--saccade-key);
+ border: 1px solid var(--saccade-border);
+ border-radius: 4px;
+}
+
+.key:focus-visible {
+ outline: 2px solid var(--saccade-accent);
+ outline-offset: 1px;
+}
+
+.active {
+ color: var(--saccade-accent-text);
+ background: var(--saccade-accent);
+ border-color: var(--saccade-accent);
+}
+
+/* A key with two characters, such as 1 and !: the one that is typed is plain, the other (the
+ symbol until shift is pressed) is smaller and lighter. */
+.stack {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.15;
+}
+
+.secondary {
+ font-size: 0.7em;
+ opacity: 0.65;
+}
+
+/* The third character of a key, typed after AltGr, sits in the corner. */
+.altgr {
+ position: absolute;
+ right: 2px;
+ bottom: 0;
+ font-size: 0.55em;
+ line-height: 1;
+ opacity: 0.75;
+}
+
+/* On a keyboard 800 px wide or less (the box has 5px of padding each side) the keys are too small
+ for a second and third character: they show only the one that is typed, and Shift and AltGr
+ show theirs when pressed. */
+@container (max-width: 790px) {
+ .hint,
+ .altgr {
+ display: none;
+ }
+}
+
+.key:disabled {
+ cursor: default;
+ opacity: 0.45;
+}
diff --git a/packages/saccade-keyboards/src/core/Keyboard.test.jsx b/packages/saccade-keyboards/src/core/Keyboard.test.jsx
new file mode 100644
index 0000000..547405e
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/Keyboard.test.jsx
@@ -0,0 +1,154 @@
+import { describe, expect, it, vi } from 'vitest';
+import { axe } from 'vitest-axe';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Keyboard } from './index.js';
+import { bottomRow, cased, keys, shiftKey } from './layout.js';
+
+const layout = {
+ id: 'en',
+ name: 'Test',
+ dir: 'ltr',
+ layers: {
+ letters: [
+ [shiftKey, ...cased('ab')],
+ bottomRow({ type: 'layer', to: 'symbols', label: '?123', name: 'Numbers' }),
+ ],
+ symbols: [[{ type: 'text', value: '1' }]],
+ },
+};
+
+describe('Keyboard', () => {
+ it('reports the key that was pressed', async () => {
+ const onKey = vi.fn();
+ render( );
+ await userEvent.click(screen.getByRole('button', { name: 'a' }));
+ expect(onKey).toHaveBeenCalledWith({ type: 'text', value: 'a' });
+ });
+
+ it('types capitals after shift and shows that it is on', async () => {
+ const onKey = vi.fn();
+ render( );
+ await userEvent.click(screen.getByRole('button', { name: 'Shift' }));
+ expect(screen.getByRole('button', { name: 'Shift' })).toHaveAttribute('aria-pressed', 'true');
+ await userEvent.click(screen.getByRole('button', { name: 'A' }));
+ expect(onKey).toHaveBeenCalledWith({ type: 'text', value: 'A' });
+ });
+
+ it('shows another layer', async () => {
+ render( );
+ await userEvent.click(screen.getByRole('button', { name: 'Numbers' }));
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument();
+ });
+
+ it('keeps the focus in the field being typed into', async () => {
+ render(
+ <>
+
+
+ >,
+ );
+ await userEvent.click(screen.getByLabelText('Field'));
+ await userEvent.click(screen.getByRole('button', { name: 'a' }));
+ expect(screen.getByLabelText('Field')).toHaveFocus();
+ });
+
+ it('shows both characters of a key and types the shifted one after shift', async () => {
+ const onKey = vi.fn();
+ const withDigits = {
+ ...layout,
+ layers: {
+ ...layout.layers,
+ letters: [
+ [shiftKey, ...keys('12', '!"'), ...cased('a')],
+ ...layout.layers.letters.slice(1),
+ ],
+ },
+ };
+ render( );
+ const one = screen.getByRole('button', { name: '1' });
+ expect(one).toHaveTextContent('!1');
+ await userEvent.click(one);
+ await userEvent.click(screen.getByRole('button', { name: 'Shift' }));
+ await userEvent.click(screen.getByRole('button', { name: 'exclamation mark' }));
+ await userEvent.click(screen.getByRole('button', { name: '1' }));
+ expect(onKey.mock.calls).toEqual([
+ [{ type: 'text', value: '1' }],
+ [{ type: 'text', value: '!' }],
+ [{ type: 'text', value: '1' }],
+ ]);
+ });
+
+ it('shows the icon and the name of a control key', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Shift' })).toHaveTextContent('⇧Shift');
+ expect(screen.getByRole('button', { name: 'Space' })).toHaveTextContent('␣space');
+ });
+
+ it('shows the AltGr character in the corner and types it after AltGr', async () => {
+ const onKey = vi.fn();
+ const withAltGr = {
+ ...layout,
+ layers: {
+ letters: [[{ type: 'altgr' }, { type: 'text', value: 'e', shift: 'E', altgr: 'é' }]],
+ },
+ };
+ render( );
+ expect(screen.getByRole('button', { name: 'e' })).toHaveTextContent('eé');
+ await userEvent.click(screen.getByRole('button', { name: 'AltGr' }));
+ expect(screen.getByRole('button', { name: 'AltGr' })).toHaveAttribute('aria-pressed', 'true');
+ await userEvent.click(screen.getByRole('button', { name: 'é' }));
+ expect(onKey).toHaveBeenCalledWith({ type: 'text', value: 'é' });
+ });
+
+ it('adds a number pad beside the keys, and turns it off with Num Lock', async () => {
+ const onKey = vi.fn();
+ render( );
+ const pad = screen.getByRole('group', { name: 'Number pad' });
+ expect(within(pad).getAllByRole('button')).toHaveLength(17);
+ await userEvent.click(within(pad).getByRole('button', { name: '7' }));
+ await userEvent.click(within(pad).getByRole('button', { name: 'comma' }));
+ expect(onKey.mock.calls.map(([key]) => key.value)).toEqual(['7', ',']);
+ await userEvent.click(within(pad).getByRole('button', { name: 'Num lock' }));
+ expect(within(pad).getByRole('button', { name: '7' })).toBeDisabled();
+ expect(within(pad).getByRole('button', { name: 'plus' })).toBeEnabled();
+ });
+
+ it('has no number pad unless asked for', () => {
+ render( );
+ expect(screen.queryByRole('group', { name: 'Number pad' })).not.toBeInTheDocument();
+ });
+
+ it('is named after the layout and takes its language', () => {
+ render( );
+ expect(screen.getByRole('group', { name: 'Test on-screen keyboard' })).toHaveAttribute(
+ 'lang',
+ 'en',
+ );
+ });
+
+ it('takes colours from a theme object', () => {
+ render( );
+ expect(screen.getByRole('group').style.getPropertyValue('--saccade-accent')).toBe('red');
+ expect(screen.getByRole('group').style.getPropertyValue('--saccade-key')).toBe('');
+ });
+
+ it('uses the device font unless told otherwise', () => {
+ render( );
+ expect(screen.getByRole('group').style.fontFamily).toContain('system-ui');
+ });
+
+ it.each([
+ ['mono', 'monospace'],
+ ['serif', 'serif'],
+ ['"Atkinson Hyperlegible", sans-serif', 'Atkinson Hyperlegible'],
+ ])('takes the %s font', (font, expected) => {
+ render( );
+ expect(screen.getByRole('group').style.fontFamily).toContain(expected);
+ });
+
+ it.each(['light', 'dark'])('has no accessibility violations in the %s theme', async (theme) => {
+ const { container } = render( );
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/applyKey.js b/packages/saccade-keyboards/src/core/applyKey.js
new file mode 100644
index 0000000..aa2c73a
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/applyKey.js
@@ -0,0 +1,39 @@
+/**
+ * @typedef {object} Selection
+ * @property {number} start Where the selection (or caret) starts.
+ * @property {number} end Where it ends. Equal to `start` for a caret.
+ */
+
+/**
+ * @typedef {object} KeyPress
+ * @property {'text' | 'backspace' | 'enter'} type
+ * @property {string} [value] What a text key types.
+ */
+
+/**
+ * Applies a key press to the text of a field: a text key replaces the selection, backspace removes
+ * the selection or the character before the caret, and enter adds a line break.
+ *
+ * @param {string} text The current text.
+ * @param {Selection} selection The selection or caret in `text`.
+ * @param {KeyPress} key The key that was pressed.
+ * @returns {{ text: string, selection: Selection }} The new text, with the caret after the change.
+ */
+export function applyKey(text, selection, key) {
+ const start = Math.min(selection.start, selection.end);
+ const end = Math.max(selection.start, selection.end);
+ let from = start;
+ let insert = '';
+ if (key.type === 'text') insert = key.value ?? '';
+ else if (key.type === 'enter') insert = '\n';
+ else if (key.type === 'backspace' && start === end && start > 0) {
+ // Remove a whole code point, so a surrogate pair is never cut in half.
+ const before = text.slice(0, start);
+ from = start - ([...before].at(-1)?.length ?? 1);
+ }
+ const caret = from + insert.length;
+ return {
+ text: text.slice(0, from) + insert + text.slice(end),
+ selection: { start: caret, end: caret },
+ };
+}
diff --git a/packages/saccade-keyboards/src/core/applyKey.test.js b/packages/saccade-keyboards/src/core/applyKey.test.js
new file mode 100644
index 0000000..ee53d03
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/applyKey.test.js
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import { applyKey } from './applyKey.js';
+
+const caret = (at) => ({ start: at, end: at });
+
+describe('applyKey', () => {
+ it('types at the caret', () => {
+ expect(applyKey('ac', caret(1), { type: 'text', value: 'b' })).toEqual({
+ text: 'abc',
+ selection: caret(2),
+ });
+ });
+
+ it('replaces the selection', () => {
+ expect(applyKey('abc', { start: 0, end: 2 }, { type: 'text', value: 'x' })).toEqual({
+ text: 'xc',
+ selection: caret(1),
+ });
+ });
+
+ it('removes the character before the caret on backspace', () => {
+ expect(applyKey('abc', caret(2), { type: 'backspace' })).toEqual({
+ text: 'ac',
+ selection: caret(1),
+ });
+ });
+
+ it('removes the selection on backspace', () => {
+ expect(applyKey('abc', { start: 2, end: 0 }, { type: 'backspace' })).toEqual({
+ text: 'c',
+ selection: caret(0),
+ });
+ });
+
+ it('does nothing on backspace at the start', () => {
+ expect(applyKey('abc', caret(0), { type: 'backspace' })).toEqual({
+ text: 'abc',
+ selection: caret(0),
+ });
+ });
+
+ it('removes a whole code point on backspace', () => {
+ expect(applyKey('a😀', caret(3), { type: 'backspace' })).toEqual({
+ text: 'a',
+ selection: caret(1),
+ });
+ });
+
+ it('adds a line break on enter', () => {
+ expect(applyKey('ab', caret(1), { type: 'enter' })).toEqual({
+ text: 'a\nb',
+ selection: caret(2),
+ });
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/grid.js b/packages/saccade-keyboards/src/core/grid.js
new file mode 100644
index 0000000..71eb95b
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/grid.js
@@ -0,0 +1,76 @@
+/** @typedef {import('./layout.js').Key} Key */
+
+/**
+ * A key and where it sits on the grid of the keyboard.
+ *
+ * @typedef {object} PlacedKey
+ * @property {Key} key The key.
+ * @property {number} column The first grid column, counted from 1.
+ * @property {number} columnSpan How many columns it covers.
+ * @property {number} row The grid row, counted from 1.
+ * @property {number} rowSpan How many rows it covers.
+ */
+
+/** How many columns the grid has. A row is split over them by the widths of its keys. */
+export const COLUMNS = 120;
+
+/**
+ * Splits `total` columns over keys in proportion to their widths, so that they add up to `total`
+ * exactly (the largest remainders get the spare columns) and no key is narrower than one column.
+ *
+ * @param {number[]} widths The width of each key.
+ * @param {number} total The columns to share.
+ * @returns {number[]} The columns of each key.
+ */
+function share(widths, total) {
+ const sum = widths.reduce((a, b) => a + b, 0);
+ const exact = widths.map((w) => (w / sum) * total);
+ const spans = exact.map((x) => Math.max(1, Math.floor(x)));
+ let spare = total - spans.reduce((a, b) => a + b, 0);
+ const byRemainder = exact
+ .map((x, i) => ({ i, rest: x - Math.floor(x) }))
+ .sort((a, b) => b.rest - a.rest);
+ for (let n = 0; spare !== 0 && byRemainder.length > 0; n = (n + 1) % byRemainder.length) {
+ const { i } = /** @type {{ i: number }} */ (byRemainder.at(n));
+ const next = (spans.at(i) ?? 1) + Math.sign(spare);
+ if (next >= 1) {
+ spans.splice(i, 1, next);
+ spare -= Math.sign(spare);
+ }
+ }
+ return spans;
+}
+
+/**
+ * Puts the keys of every row on the grid. A row fills the width, like a row of a physical keyboard.
+ * A key with `rowSpan` keeps its columns in the rows below, so the keys there share what is left;
+ * that works when the tall key sits at the start or the end of its row, like Enter.
+ *
+ * @param {Key[][]} rows The rows of a layer.
+ * @returns {PlacedKey[][]} The same rows with the place of each key.
+ */
+export function placeKeys(rows) {
+ /** @type {Map>} Columns taken by a tall key above, by row. */
+ const taken = new Map();
+ return rows.map((row, r) => {
+ const used = taken.get(r) ?? new Set();
+ const free = Array.from({ length: COLUMNS }, (_, c) => c).filter((c) => !used.has(c));
+ const spans = share(
+ row.map((key) => key.width ?? 1),
+ free.length,
+ );
+ let at = 0;
+ return row.map((key, k) => {
+ const columnSpan = spans.at(k) ?? 1;
+ const start = free.at(at) ?? 0;
+ at += columnSpan;
+ const rowSpan = key.rowSpan ?? 1;
+ for (let below = r + 1; below < r + rowSpan; below += 1) {
+ const cells = taken.get(below) ?? new Set();
+ for (let c = start; c < start + columnSpan; c += 1) cells.add(c);
+ taken.set(below, cells);
+ }
+ return { key, column: start + 1, columnSpan, row: r + 1, rowSpan };
+ });
+ });
+}
diff --git a/packages/saccade-keyboards/src/core/grid.test.js b/packages/saccade-keyboards/src/core/grid.test.js
new file mode 100644
index 0000000..cc0fcec
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/grid.test.js
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { COLUMNS, placeKeys } from './grid.js';
+
+const key = (value, extra = {}) => ({ type: 'text', value, ...extra });
+
+describe('placeKeys', () => {
+ it('fills every row with its keys, in proportion to their widths', () => {
+ const [row] = placeKeys([[key('a'), key('b', { width: 2 }), key('c')]]);
+ expect(row.map((placed) => placed.columnSpan)).toEqual([30, 60, 30]);
+ expect(row.map((placed) => placed.column)).toEqual([1, 31, 91]);
+ });
+
+ it('hands out the spare columns so that a row is always full', () => {
+ const [row] = placeKeys([Array.from({ length: 7 }, (_, i) => key(String(i)))]);
+ expect(row.reduce((sum, placed) => sum + placed.columnSpan, 0)).toBe(COLUMNS);
+ });
+
+ it('keeps the columns of a tall key at the end of the rows below it', () => {
+ const [top, below] = placeKeys([
+ [key('a'), key('b'), { type: 'enter', width: 2, rowSpan: 2 }],
+ [key('c'), key('d')],
+ ]);
+ const enter = top[2];
+ expect(enter.rowSpan).toBe(2);
+ expect(below.at(-1).column + below.at(-1).columnSpan - 1).toBe(enter.column - 1);
+ expect(below[0].column).toBe(1);
+ expect(below.reduce((sum, placed) => sum + placed.columnSpan, 0)).toBe(
+ COLUMNS - enter.columnSpan,
+ );
+ });
+
+ it('keeps the columns of a tall key at the start of the rows below it', () => {
+ const [top, below] = placeKeys([[{ type: 'enter', rowSpan: 2 }, key('a')], [key('b')]]);
+ expect(top[0].column).toBe(1);
+ expect(below[0].column).toBe(top[0].columnSpan + 1);
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/index.js b/packages/saccade-keyboards/src/core/index.js
new file mode 100644
index 0000000..d64c9ae
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/index.js
@@ -0,0 +1,19 @@
+export { Keyboard } from './Keyboard.jsx';
+export { useKeyboard, typedValue } from './useKeyboard.js';
+export { applyKey } from './applyKey.js';
+export {
+ PUNCTUATION_NAMES,
+ altGrKey,
+ backspaceKey,
+ bottomRow,
+ capsKey,
+ cased,
+ keys,
+ numpadRows,
+ physicalRows,
+ shiftKey,
+ symbolsLayer,
+ validateLayout,
+} from './layout.js';
+
+/** @typedef {import('./layout.js').Layout} Layout */
diff --git a/packages/saccade-keyboards/src/core/layout.js b/packages/saccade-keyboards/src/core/layout.js
new file mode 100644
index 0000000..f9c7984
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/layout.js
@@ -0,0 +1,390 @@
+// A keyboard layout is data. Its `layers` map a layer name to rows, the first layer being the one
+// shown first. A row is a list of keys; a key is one of
+// { type: 'text', value, shift?, altgr? } types `value` (or `shift` while shift is on, or
+// `altgr` after the AltGr key)
+// { type: 'shift' | 'caps' | 'altgr' | 'backspace' | 'enter' | 'space' | 'close' }
+// { type: 'layer', to, label, name? } switches layer
+// `width` is the share of a row the key takes (1 is one ordinary key).
+
+/**
+ * @typedef {object} TextKey
+ * @property {'text'} type
+ * @property {string} value What the key types.
+ * @property {string} [shift] What it types while shift is on.
+ * @property {string} [capital] What it types while caps lock is on, when that is not the shifted
+ * character, such as É on the key that types é and 2 with shift (a French keyboard).
+ * @property {boolean} [numpad] A key of the number pad, which is off while Num Lock is off.
+ * @property {string} [altgr] What it types after the AltGr key, as the third character of a key.
+ * Shift capitalises it when it is a letter.
+ * @property {number} [width] Share of the row, 1 being an ordinary key.
+ * @property {number} [rowSpan] How many rows tall the key is, such as 2 for an Enter key. It sits at
+ * the start or the end of its row, and the rows below leave room for it.
+ */
+
+/**
+ * @typedef {object} ActionKey
+ * @property {'shift' | 'caps' | 'altgr' | 'numlock' | 'currency' | 'backspace' | 'enter' | 'space' | 'close'} type
+ * @property {number} [width] Share of the row, 1 being an ordinary key.
+ * @property {number} [rowSpan] How many rows tall the key is, such as 2 for an Enter key. It sits at
+ * the start or the end of its row, and the rows below leave room for it.
+ */
+
+/**
+ * @typedef {object} LayerKey
+ * @property {'layer'} type
+ * @property {string} to The layer to show.
+ * @property {string} label Text on the key.
+ * @property {string} [name] What a screen reader says (the label).
+ * @property {number} [width] Share of the row, 1 being an ordinary key.
+ * @property {number} [rowSpan] How many rows tall the key is, such as 2 for an Enter key. It sits at
+ * the start or the end of its row, and the rows below leave room for it.
+ */
+
+/** @typedef {TextKey | ActionKey | LayerKey} Key */
+
+/**
+ * @typedef {object} Layout
+ * @property {string} id Language code, such as `en`.
+ * @property {string} name Name of the language, such as `English`.
+ * @property {'ltr' | 'rtl'} dir Reading direction of the language. Keys always sit left to right.
+ * @property {string[]} [fonts] Font families the keys need, for the host to load.
+ * @property {Record} layers Layer name to rows of keys.
+ * @property {Record} [names] What a screen reader says for a character.
+ * @property {string} [decimal] The sign of the decimal key of the number pad ('.').
+ */
+
+const ACTIONS = ['shift', 'caps', 'altgr', 'numlock', 'backspace', 'enter', 'space', 'close'];
+
+/**
+ * Keys that type `normal`, and `shifted` (one character each) while shift is on.
+ *
+ * @param {string} normal Characters, one per key.
+ * @param {string} [shifted] Their shifted characters, in the same order; a space leaves that key
+ * without one.
+ * @returns {TextKey[]} The keys.
+ */
+export function keys(normal, shifted = '') {
+ const upper = [...shifted];
+ return [...normal].map((value, i) => {
+ const capital = upper.at(i);
+ return capital && capital !== ' '
+ ? { type: 'text', value, shift: capital }
+ : { type: 'text', value };
+ });
+}
+
+/**
+ * Keys for letters of a script with case: each types the letter, and its capital with shift.
+ *
+ * @param {string} letters Lower case letters, one per key.
+ * @returns {TextKey[]} The keys.
+ */
+export const cased = (letters) => keys(letters, letters.toUpperCase());
+
+/** @type {ActionKey} */
+export const backspaceKey = { type: 'backspace', width: 1.5 };
+/** @type {ActionKey} */
+export const shiftKey = { type: 'shift', width: 1.5 };
+/** @type {ActionKey} */
+export const capsKey = { type: 'caps', width: 1.5 };
+/** @type {ActionKey} */
+export const altGrKey = { type: 'altgr', width: 1.5 };
+
+/**
+ * Keys from a pair of strings: what the keys type, and what they type with shift, one character
+ * each in the same order.
+ *
+ * @param {string[]} pair The normal and the shifted characters.
+ * @returns {TextKey[]} The keys.
+ */
+const keysOf = ([normal, shifted]) => keys(normal, shifted);
+
+/**
+ * The four rows of a physical keyboard, for the main view of a layout.
+ *
+ * Row 1 is the number row ending in backspace. Row 2 and row 3 hold the letters and, with `iso`,
+ * the tall Enter key that spans both; otherwise Enter ends row 3. Row 4 is the bottom row of
+ * letters between two shift keys. Add a last row with `bottomRow` or your own.
+ *
+ * @param {object} options What the rows hold.
+ * @param {string[]} options.letters The letters of the three rows, one per key.
+ * @param {string[]} options.digits The number row without backspace, such as the grave accent,
+ * 1 to 0, hyphen and equals.
+ * @param {string[]} options.end2 The signs after the letters of row 2, such as brackets.
+ * @param {string[]} options.end3 The signs after the letters of row 3, such as a semicolon.
+ * @param {string[]} options.end4 The signs after the letters of row 4, such as a comma and a stop.
+ * @param {string[]} [options.extra4] The extra key at the start of row 4 on an ISO keyboard.
+ * @param {boolean} [options.iso] An ISO keyboard (tall Enter, extra key in row 4) rather than ANSI.
+ * @param {Record} [options.altgr] The third character of keys, by what they type:
+ * `{ 4: '€' }` types a euro after AltGr on the 4 key.
+ * @param {Record} [options.capitals] What keys type with caps lock when that is
+ * not their shifted character, by what they type: `{ é: 'É' }`.
+ * @returns {Key[][]} The four rows.
+ */
+export function physicalRows({
+ letters,
+ digits,
+ end2,
+ end3,
+ end4,
+ extra4,
+ iso = false,
+ altgr = {},
+ capitals = {},
+}) {
+ const third = new Map(Object.entries(altgr));
+ const capital = new Map(Object.entries(capitals));
+ const [top, middle, bottom] = letters;
+ const end = keysOf(end2);
+ // ANSI has the backslash as a wider last key of row 2 and Enter at the end of row 3.
+ const row2 = iso
+ ? [...cased(top), ...end, /** @type {Key} */ ({ type: 'enter', width: 1.5, rowSpan: 2 })]
+ : [...cased(top), ...end.slice(0, -1), { .../** @type {TextKey} */ (end.at(-1)), width: 1.5 }];
+ const rows = [
+ [...keysOf(digits), { ...backspaceKey, width: 2 }],
+ row2,
+ [
+ { ...capsKey, width: 1.75 },
+ ...cased(middle),
+ ...keysOf(end3),
+ ...(iso ? [] : [/** @type {Key} */ ({ type: 'enter', width: 2.25 })]),
+ ],
+ [
+ { ...shiftKey, width: iso ? 1.25 : 2.25 },
+ ...(extra4 ? keysOf(extra4) : []),
+ ...cased(bottom),
+ ...keysOf(end4),
+ { ...shiftKey, width: 2.75 },
+ ],
+ ];
+ return rows.map((row) =>
+ row.map((key) => {
+ if (key.type !== 'text') return key;
+ const extra = third.get(key.value);
+ const upper = capital.get(key.value);
+ return extra || upper
+ ? { ...key, ...(extra && { altgr: extra }), ...(upper && { capital: upper }) }
+ : key;
+ }),
+ );
+}
+
+/**
+ * The rows of a number pad: Num Lock and the operators, the digits with a tall plus and a tall
+ * Enter, and a wide zero beside the decimal key. Its keys are marked `numpad`, so they are off
+ * while Num Lock is off. The keyboard shows it beside the main keys with its `numpad` prop.
+ *
+ * @param {string} [decimal] The sign of the decimal key.
+ * @returns {Key[][]} The five rows.
+ */
+export function numpadRows(decimal = '.') {
+ /** @param {string} value @param {Partial} [extra] @returns {TextKey} */
+ const text = (value, extra = {}) => ({ type: 'text', value, numpad: true, ...extra });
+ return [
+ [{ type: 'numlock' }, ...keys('/*-')],
+ [...[...'789'].map((digit) => text(digit)), { type: 'text', value: '+', rowSpan: 2 }],
+ [...'456'].map((digit) => text(digit)),
+ [...[...'123'].map((digit) => text(digit)), { type: 'enter', rowSpan: 2 }],
+ [text('0', { width: 2 }), text(decimal)],
+ ];
+}
+
+/**
+ * The last row of every layer: a key to another layer (or several), `marks` (a comma and a full
+ * stop by default), space, enter and hide.
+ *
+ * @param {LayerKey | LayerKey[]} layerKey The key (or keys) to another layer.
+ * @param {string} [marks] Punctuation keys beside the space key.
+ * @returns {Key[]} The row.
+ */
+export function bottomRow(layerKey, marks = ',.') {
+ return [
+ ...[layerKey].flat().map((key) => ({ ...key, width: 1.5 })),
+ ...keys(marks),
+ { type: 'space', width: 5 },
+ { type: 'enter', width: 1.5 },
+ { type: 'close', width: 1.5 },
+ ];
+}
+
+/**
+ * The numbers and symbols layer every language can share, but for the row of digits (so a language
+ * can show its own) and the key back to its letters.
+ *
+ * @param {string} digits The row of digits, one per key; empty when the letters layer has them.
+ * @param {LayerKey} backKey The key back to the letters.
+ * @returns {Key[][]} The rows.
+ */
+export function symbolsLayer(digits, backKey) {
+ return [
+ ...(digits ? [keys(digits)] : []),
+ keys('-/:;()$&@"'),
+ keys('[]{}#%^*+='),
+ [...keys("_\\|~<>?!'"), backspaceKey],
+ bottomRow(backKey),
+ ];
+}
+
+/** What a screen reader says for the punctuation of the shared symbols layer. */
+export const PUNCTUATION_NAMES = {
+ ',': 'comma',
+ '.': 'full stop',
+ '-': 'hyphen',
+ '/': 'slash',
+ ':': 'colon',
+ ';': 'semicolon',
+ '(': 'open parenthesis',
+ ')': 'close parenthesis',
+ $: 'dollar',
+ '&': 'ampersand',
+ '@': 'at sign',
+ '"': 'double quote',
+ '[': 'open bracket',
+ ']': 'close bracket',
+ '{': 'open brace',
+ '}': 'close brace',
+ '#': 'hash',
+ '%': 'percent',
+ '^': 'caret',
+ '*': 'asterisk',
+ '+': 'plus',
+ '=': 'equals',
+ _: 'underscore',
+ '\\': 'backslash',
+ '|': 'vertical bar',
+ '~': 'tilde',
+ '<': 'less than',
+ '>': 'greater than',
+ '?': 'question mark',
+ '!': 'exclamation mark',
+ "'": 'apostrophe',
+ '`': 'grave accent',
+ '¬': 'not sign',
+ '¦': 'broken bar',
+ '£': 'pound',
+ '€': 'euro',
+ '¥': 'yen',
+ '¢': 'cent',
+ '°': 'degree',
+ '¿': 'inverted question mark',
+ '¡': 'inverted exclamation mark',
+ '«': 'opening guillemet',
+ '»': 'closing guillemet',
+ '‘': 'opening single quote',
+ '’': 'closing single quote',
+ '“': 'opening double quote',
+ '”': 'closing double quote',
+ '…': 'ellipsis',
+ '–': 'en dash',
+ '—': 'em dash',
+ '•': 'bullet',
+ '©': 'copyright',
+ '®': 'registered',
+ '™': 'trademark',
+ '§': 'section sign',
+ '₹': 'rupee',
+ '¤': 'currency sign',
+ '²': 'superscript two',
+ '³': 'superscript three',
+ '¼': 'one quarter',
+ '½': 'one half',
+ '¾': 'three quarters',
+ '×': 'multiplication sign',
+ '¶': 'pilcrow',
+ µ: 'micro',
+ '´': 'acute accent',
+ '¨': 'diaeresis',
+ º: 'masculine ordinal',
+ ª: 'feminine ordinal',
+ '·': 'middle dot',
+};
+
+/** @param {unknown} value */
+const isText = (value) => typeof value === 'string' && value !== '';
+
+/**
+ * @param {any} key
+ * @param {string} where
+ * @param {string[]} layerNames
+ * @returns {string[]}
+ */
+function keyProblems(key, where, layerNames) {
+ if (key === null || typeof key !== 'object') return [`${where}: a key must be an object`];
+ const problems = [];
+ if (key.width !== undefined && !(typeof key.width === 'number' && key.width > 0)) {
+ problems.push(`${where}: width must be a positive number`);
+ }
+ if (key.rowSpan !== undefined && !(Number.isInteger(key.rowSpan) && key.rowSpan >= 1)) {
+ problems.push(`${where}: rowSpan must be a whole number of rows`);
+ }
+ if (key.type === 'text') {
+ if (!isText(key.value)) problems.push(`${where}: a text key needs a value`);
+ if (key.shift !== undefined && !isText(key.shift)) {
+ problems.push(`${where}: shift must be a non-empty string`);
+ }
+ if (key.capital !== undefined && !isText(key.capital)) {
+ problems.push(`${where}: capital must be a non-empty string`);
+ }
+ if (key.numpad !== undefined && typeof key.numpad !== 'boolean') {
+ problems.push(`${where}: numpad must be true or false`);
+ }
+ if (key.altgr !== undefined && !isText(key.altgr)) {
+ problems.push(`${where}: altgr must be a non-empty string`);
+ }
+ } else if (key.type === 'layer') {
+ if (!layerNames.includes(key.to)) problems.push(`${where}: no layer named "${key.to}"`);
+ if (!isText(key.label)) problems.push(`${where}: a layer key needs a label`);
+ } else if (!ACTIONS.includes(key.type)) {
+ problems.push(`${where}: unknown key type "${key.type}"`);
+ }
+ return problems;
+}
+
+/**
+ * Checks a layout against the documented format.
+ *
+ * @param {unknown} layout The layout to check.
+ * @returns {string[]} What is wrong with it; empty when it is valid.
+ */
+export function validateLayout(layout) {
+ if (layout === null || typeof layout !== 'object') return ['a layout must be an object'];
+ const { id, name, dir, fonts, layers, names, decimal } = /** @type {Record} */ (
+ layout
+ );
+ const problems = [];
+ if (!isText(id)) problems.push('id must be a non-empty string');
+ if (!isText(name)) problems.push('name must be a non-empty string');
+ if (dir !== 'ltr' && dir !== 'rtl') problems.push('dir must be "ltr" or "rtl"');
+ if (fonts !== undefined && !(Array.isArray(fonts) && fonts.every(isText))) {
+ problems.push('fonts must be a list of font family names');
+ }
+ if (decimal !== undefined && !isText(decimal)) {
+ problems.push('decimal must be a non-empty string');
+ }
+ if (names !== undefined && (names === null || typeof names !== 'object')) {
+ problems.push('names must be an object');
+ }
+ if (layers === null || typeof layers !== 'object' || Object.keys(layers).length === 0) {
+ problems.push('layers must be an object with at least one layer');
+ return problems;
+ }
+ const layerNames = Object.keys(layers);
+ Object.entries(layers).forEach(([layer, rows]) => {
+ if (!Array.isArray(rows) || rows.length === 0) {
+ problems.push(`layer "${layer}": must be a non-empty list of rows`);
+ return;
+ }
+ rows.forEach((row, r) => {
+ if (!Array.isArray(row) || row.length === 0) {
+ problems.push(`layer "${layer}", row ${r + 1}: must be a non-empty list of keys`);
+ return;
+ }
+ row.forEach((key, k) => {
+ problems.push(
+ ...keyProblems(key, `layer "${layer}", row ${r + 1}, key ${k + 1}`, layerNames),
+ );
+ });
+ });
+ });
+ return problems;
+}
diff --git a/packages/saccade-keyboards/src/core/layout.test.js b/packages/saccade-keyboards/src/core/layout.test.js
new file mode 100644
index 0000000..f314e72
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/layout.test.js
@@ -0,0 +1,129 @@
+import { describe, expect, it } from 'vitest';
+import {
+ bottomRow,
+ cased,
+ keys,
+ numpadRows,
+ physicalRows,
+ symbolsLayer,
+ validateLayout,
+} from './layout.js';
+
+const toSymbols = { type: 'layer', to: 'symbols', label: '?123' };
+const toLetters = { type: 'layer', to: 'letters', label: 'abc' };
+
+const layout = {
+ id: 'xx',
+ name: 'Test',
+ dir: 'ltr',
+ fonts: ['Test Sans'],
+ layers: {
+ letters: [[...cased('ab')], bottomRow(toSymbols)],
+ symbols: symbolsLayer('123', toLetters),
+ },
+};
+
+describe('layout helpers', () => {
+ it('pairs each key with its shifted character', () => {
+ expect(keys('ab', 'A')).toEqual([
+ { type: 'text', value: 'a', shift: 'A' },
+ { type: 'text', value: 'b' },
+ ]);
+ });
+
+ it('leaves the row of digits out of the symbols layer when there are none', () => {
+ const withDigits = symbolsLayer('123', toLetters);
+ const without = symbolsLayer('', toLetters);
+ expect(without).toHaveLength(withDigits.length - 1);
+ expect(without).toEqual(withDigits.slice(1));
+ });
+
+ it('checks the row span of a key', () => {
+ const tall = (rowSpan) => ({
+ ...layout,
+ layers: { letters: [[{ type: 'enter', rowSpan }], bottomRow(toSymbols)], symbols: [[]] },
+ });
+ expect(validateLayout(tall(2)).join()).not.toContain('rowSpan');
+ expect(validateLayout(tall(0)).join()).toContain('rowSpan');
+ expect(validateLayout(tall(1.5)).join()).toContain('rowSpan');
+ });
+
+ it('builds the four rows of a physical keyboard', () => {
+ const options = {
+ letters: ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'],
+ digits: ['1234567890', '!@#$%^&*()'],
+ end2: ['[]', '{}'],
+ end3: [';', ':'],
+ end4: [',.', '<>'],
+ };
+ const ansi = physicalRows(options);
+ expect(ansi).toHaveLength(4);
+ expect(ansi[0].at(-1).type).toBe('backspace');
+ expect(ansi[2].at(-1).type).toBe('enter');
+ const iso = physicalRows({ ...options, iso: true, extra4: ['\\', '|'] });
+ expect(iso[1].at(-1)).toMatchObject({ type: 'enter', rowSpan: 2 });
+ expect(iso[2].some((key) => key.type === 'enter')).toBe(false);
+ expect(iso[3][1].value).toBe('\\');
+ });
+
+ it('builds a number pad with a tall plus and Enter and a wide zero', () => {
+ const rows = numpadRows(',');
+ expect(rows).toHaveLength(5);
+ expect(rows[0].map((key) => key.value ?? key.type)).toEqual(['numlock', '/', '*', '-']);
+ expect(rows[1].at(-1)).toMatchObject({ value: '+', rowSpan: 2 });
+ expect(rows[3].at(-1)).toMatchObject({ type: 'enter', rowSpan: 2 });
+ expect(rows[4][0]).toMatchObject({ value: '0', width: 2 });
+ expect(rows[4][1].value).toBe(',');
+ expect(rows.flat().filter((key) => key.numpad)).toHaveLength(11);
+ });
+
+ it('checks the decimal sign and the numpad mark', () => {
+ expect(validateLayout({ ...layout, decimal: '' }).join()).toContain('decimal');
+ expect(validateLayout({ ...layout, decimal: ',' })).toEqual([]);
+ });
+
+ it('types the capital of a cased letter with shift', () => {
+ expect(cased('a')).toEqual([{ type: 'text', value: 'a', shift: 'A' }]);
+ });
+});
+
+describe('validateLayout', () => {
+ it('accepts a well-formed layout', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ });
+
+ it('rejects something that is not an object', () => {
+ expect(validateLayout(null)).toEqual(['a layout must be an object']);
+ });
+
+ it('reports a missing id, name and direction', () => {
+ const problems = validateLayout({ layers: layout.layers });
+ expect(problems).toEqual([
+ 'id must be a non-empty string',
+ 'name must be a non-empty string',
+ 'dir must be "ltr" or "rtl"',
+ ]);
+ });
+
+ it('reports a layer key that points nowhere', () => {
+ const problems = validateLayout({ ...layout, layers: { letters: [[toSymbols]] } });
+ expect(problems).toEqual(['layer "letters", row 1, key 1: no layer named "symbols"']);
+ });
+
+ it('reports an unknown key type and a bad width', () => {
+ const problems = validateLayout({
+ ...layout,
+ layers: { letters: [[{ type: 'tab' }, { type: 'space', width: 0 }]] },
+ });
+ expect(problems).toEqual([
+ 'layer "letters", row 1, key 1: unknown key type "tab"',
+ 'layer "letters", row 1, key 2: width must be a positive number',
+ ]);
+ });
+
+ it('reports a layout without layers', () => {
+ expect(validateLayout({ ...layout, layers: {} })).toEqual([
+ 'layers must be an object with at least one layer',
+ ]);
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/useKeyboard.js b/packages/saccade-keyboards/src/core/useKeyboard.js
new file mode 100644
index 0000000..df30a61
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/useKeyboard.js
@@ -0,0 +1,166 @@
+import { useState } from 'react';
+import { PUNCTUATION_NAMES } from './layout.js';
+
+/** @typedef {import('./layout.js').Key} Key */
+/** @typedef {import('./layout.js').Layout} Layout */
+/** @typedef {import('./applyKey.js').KeyPress} KeyPress */
+
+/**
+ * @typedef {object} UseKeyboardOptions
+ * @property {Layout} layout The layout to drive.
+ * @property {(key: KeyPress) => void} onKey A key was pressed (the space key types a space).
+ * @property {() => void} [onClose] The hide key was pressed.
+ */
+
+/**
+ * @typedef {object} KeyDescription
+ * @property {string} label Text to show on the key; the icon of a control key.
+ * @property {string} [caption] Text to show under the icon of a control key, such as `Shift`.
+ * @property {string} name What a screen reader says.
+ * @property {boolean} [pressed] Whether the key is on, for the shift, caps lock, AltGr and Num Lock keys.
+ * @property {boolean} [disabled] Whether the key is off, for the number pad while Num Lock is off.
+ */
+
+/** @type {Record} */
+const FIXED = {
+ shift: { label: '⇧', caption: 'Shift', name: 'Shift' },
+ caps: { label: '⇪', caption: 'Caps', name: 'Caps lock' },
+ altgr: { label: '⎇', caption: 'AltGr', name: 'AltGr' },
+ numlock: { label: '⇭', caption: 'Num', name: 'Num lock' },
+ backspace: { label: '⌫', caption: 'Backspace', name: 'Backspace' },
+ enter: { label: '⏎', caption: 'Enter', name: 'Enter' },
+ space: { label: '␣', caption: 'space', name: 'Space' },
+ close: { label: '⌄', caption: 'Hide', name: 'Hide keyboard' },
+ currency: { label: '$£€', caption: 'Currency', name: 'Currency symbols' },
+};
+
+/** The signs of the currency pad. */
+export const CURRENCY_SIGNS = '$€£¥₹₩₽₺₪₫₱₿¢₴₦₡';
+
+/**
+ * What a text key types: its AltGr character after the AltGr key (capitalised while shift is on,
+ * if it is a letter), otherwise its shifted character while shift is on, if it has one.
+ *
+ * @param {import('./layout.js').TextKey} key The key.
+ * @param {boolean} shifted Whether shift is on.
+ * @param {boolean} [altGr] Whether AltGr is on.
+ * @param {boolean} [capsLock] Whether caps lock is on, for a key with a `capital` of its own.
+ * @returns {string} The character.
+ */
+export function typedValue(key, shifted, altGr = false, capsLock = false) {
+ if (altGr && key.altgr) {
+ const capital = key.altgr.toUpperCase();
+ return shifted && capital.length === key.altgr.length ? capital : key.altgr;
+ }
+ if (capsLock && key.capital) return key.capital;
+ return shifted && key.shift ? key.shift : key.value;
+}
+
+// A sign that only sits on a letter (a vowel sign, a virama) is shown on a dotted circle.
+/** @param {string} value */
+const shown = (value) => (/^\p{M}$/u.test(value) ? `◌${value}` : value);
+
+/**
+ * The state and behaviour of an on-screen keyboard, without any markup, so a custom UI can render
+ * the keys itself. Shift is `'off'`, `'once'` (the next key, from the shift key) or `'caps'` (from
+ * the caps lock key). AltGr is on for the next key only, like shift. Num Lock starts on; while it
+ * is off the keys of the number pad do nothing.
+ *
+ * @param {UseKeyboardOptions} options Layout and callbacks.
+ * @returns {{
+ * layer: string,
+ * shift: 'off' | 'once' | 'caps',
+ * altGr: boolean,
+ * numLock: boolean,
+ * currencyOpen: boolean,
+ * rows: Key[][],
+ * press: (key: Key) => void,
+ * describe: (key: Key) => KeyDescription,
+ * }} What to render and how to react.
+ */
+export function useKeyboard({ layout, onKey, onClose }) {
+ const [chosen, setChosen] = useState('');
+ const [shift, setShift] = useState(/** @type {'off' | 'once' | 'caps'} */ ('off'));
+ const [altGr, setAltGr] = useState(false);
+ const [numLock, setNumLock] = useState(true);
+ const [currencyOpen, setCurrencyOpen] = useState(false);
+ // Fall back to the first layer when the layout changes to one without the chosen layer.
+ const layers = new Map(Object.entries(layout.layers));
+ const layer = layers.has(chosen) ? chosen : [...layers.keys()][0];
+ /** @type {Map} */
+ const names = new Map(Object.entries({ ...PUNCTUATION_NAMES, ...layout.names }));
+
+ // Caps lock capitalises letters only; a key such as a digit keeps its character without shift.
+ /** @param {import('./layout.js').TextKey} key */
+ const isShifted = (key) =>
+ shift === 'once' || (shift === 'caps' && key.shift === key.value.toUpperCase());
+
+ /** @param {Key} key */
+ const press = (key) => {
+ if (key.type === 'text' && key.numpad && !numLock) return;
+ switch (key.type) {
+ case 'text':
+ onKey({ type: 'text', value: typedValue(key, isShifted(key), altGr, shift === 'caps') });
+ if (shift === 'once') setShift('off');
+ setAltGr(false);
+ break;
+ case 'space':
+ onKey({ type: 'text', value: ' ' });
+ if (shift === 'once') setShift('off');
+ setAltGr(false);
+ break;
+ case 'shift':
+ setShift(shift === 'off' ? 'once' : 'off');
+ break;
+ case 'altgr':
+ setAltGr(!altGr);
+ break;
+ case 'numlock':
+ setNumLock(!numLock);
+ break;
+ case 'currency':
+ setCurrencyOpen(!currencyOpen);
+ break;
+ case 'caps':
+ setShift(shift === 'caps' ? 'off' : 'caps');
+ break;
+ case 'layer':
+ setChosen(key.to);
+ setShift('off');
+ setAltGr(false);
+ break;
+ case 'close':
+ onClose?.();
+ break;
+ default:
+ onKey({ type: key.type });
+ }
+ };
+
+ /** @param {Key} key @returns {KeyDescription} */
+ const describe = (key) => {
+ if (key.type === 'text') {
+ const value = typedValue(key, isShifted(key), altGr, shift === 'caps');
+ const description = { label: shown(value), name: names.get(value) ?? value };
+ return key.numpad ? { ...description, disabled: !numLock } : description;
+ }
+ if (key.type === 'layer') return { label: key.label, name: key.name ?? key.label };
+ if (key.type === 'shift') return { ...FIXED.shift, pressed: shift === 'once' };
+ if (key.type === 'caps') return { ...FIXED.caps, pressed: shift === 'caps' };
+ if (key.type === 'altgr') return { ...FIXED.altgr, pressed: altGr };
+ if (key.type === 'numlock') return { ...FIXED.numlock, pressed: numLock };
+ if (key.type === 'currency') return { ...FIXED.currency, pressed: currencyOpen };
+ return FIXED[key.type];
+ };
+
+ return {
+ layer,
+ shift,
+ altGr,
+ numLock,
+ currencyOpen,
+ rows: /** @type {Key[][]} */ (layers.get(layer)),
+ press,
+ describe,
+ };
+}
diff --git a/packages/saccade-keyboards/src/core/useKeyboard.test.jsx b/packages/saccade-keyboards/src/core/useKeyboard.test.jsx
new file mode 100644
index 0000000..f90c53c
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/useKeyboard.test.jsx
@@ -0,0 +1,176 @@
+import { describe, expect, it, vi } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import { useKeyboard } from './useKeyboard.js';
+import { bottomRow, capsKey, cased, shiftKey } from './layout.js';
+
+const layout = {
+ id: 'xx',
+ name: 'Test',
+ dir: 'ltr',
+ layers: {
+ letters: [
+ [shiftKey, capsKey, ...cased('ab')],
+ bottomRow({ type: 'layer', to: 'symbols', label: '?123' }),
+ ],
+ symbols: [[{ type: 'text', value: '1' }]],
+ },
+};
+const [shift, caps] = layout.layers.letters[0];
+const a = layout.layers.letters[0][2];
+
+describe('useKeyboard', () => {
+ it('turns shift off after one key, and keeps it on with caps', () => {
+ const onKey = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey }));
+
+ act(() => result.current.press(shift));
+ act(() => result.current.press(a));
+ act(() => result.current.press(a));
+ expect(onKey.mock.calls).toEqual([
+ [{ type: 'text', value: 'A' }],
+ [{ type: 'text', value: 'a' }],
+ ]);
+
+ act(() => result.current.press(caps));
+ expect(result.current.shift).toBe('caps');
+ act(() => result.current.press(a));
+ act(() => result.current.press(a));
+ expect(onKey).toHaveBeenLastCalledWith({ type: 'text', value: 'A' });
+ expect(onKey).toHaveBeenCalledTimes(4);
+ });
+
+ it('unlocks caps lock with the caps key or shift, and shift toggles off', () => {
+ const { result } = renderHook(() => useKeyboard({ layout, onKey: vi.fn() }));
+ act(() => result.current.press(caps));
+ act(() => result.current.press(caps));
+ expect(result.current.shift).toBe('off');
+ act(() => result.current.press(caps));
+ act(() => result.current.press(shift));
+ expect(result.current.shift).toBe('off');
+ act(() => result.current.press(shift));
+ expect(result.current.shift).toBe('once');
+ act(() => result.current.press(shift));
+ expect(result.current.shift).toBe('off');
+ });
+
+ it('types the AltGr character of the next key only, capitalised with shift', () => {
+ const onKey = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey }));
+ const e = { type: 'text', value: 'e', shift: 'E', altgr: 'é' };
+ const four = { type: 'text', value: '4', shift: '$', altgr: '€' };
+ const altgr = { type: 'altgr' };
+
+ act(() => result.current.press(altgr));
+ expect(result.current.altGr).toBe(true);
+ expect(result.current.describe(altgr).pressed).toBe(true);
+ expect(result.current.describe(e).name).toBe('é');
+ act(() => result.current.press(e));
+ act(() => result.current.press(e));
+ act(() => result.current.press(altgr));
+ act(() => result.current.press(shift));
+ act(() => result.current.press(e));
+ act(() => result.current.press(caps));
+ act(() => result.current.press(altgr));
+ act(() => result.current.press(e));
+ act(() => result.current.press(altgr));
+ act(() => result.current.press(four));
+ expect(onKey.mock.calls.map(([key]) => key.value)).toEqual(['é', 'e', 'É', 'É', '€']);
+ expect(result.current.altGr).toBe(false);
+ });
+
+ it('types the capital of a key with caps lock, and its shifted character with shift', () => {
+ const onKey = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey }));
+ const accent = { type: 'text', value: 'é', shift: '2', capital: 'É' };
+ act(() => result.current.press(caps));
+ act(() => result.current.press(accent));
+ act(() => result.current.press(caps));
+ act(() => result.current.press(shift));
+ act(() => result.current.press(accent));
+ act(() => result.current.press(accent));
+ expect(onKey.mock.calls.map(([key]) => key.value)).toEqual(['É', '2', 'é']);
+ });
+
+ it('types a key as usual after AltGr when it has no AltGr character', () => {
+ const onKey = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey }));
+ act(() => result.current.press({ type: 'altgr' }));
+ act(() => result.current.press(a));
+ expect(onKey).toHaveBeenCalledWith({ type: 'text', value: 'a' });
+ expect(result.current.altGr).toBe(false);
+ });
+
+ it('turns the digits and the decimal key of the number pad off with Num Lock', () => {
+ const onKey = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey }));
+ const numlock = { type: 'numlock' };
+ const seven = { type: 'text', value: '7', numpad: true };
+ const plus = { type: 'text', value: '+' };
+ expect(result.current.numLock).toBe(true);
+ expect(result.current.describe(numlock)).toMatchObject({ pressed: true, name: 'Num lock' });
+ act(() => result.current.press(seven));
+ act(() => result.current.press(numlock));
+ expect(result.current.numLock).toBe(false);
+ expect(result.current.describe(seven).disabled).toBe(true);
+ act(() => result.current.press(seven));
+ act(() => result.current.press(plus));
+ act(() => result.current.press(numlock));
+ act(() => result.current.press(seven));
+ expect(onKey.mock.calls.map(([key]) => key.value)).toEqual(['7', '+', '7']);
+ });
+
+ it('switches layer and clears shift', () => {
+ const { result } = renderHook(() => useKeyboard({ layout, onKey: vi.fn() }));
+ act(() => result.current.press(shift));
+ act(() => result.current.press({ type: 'layer', to: 'symbols', label: '?123' }));
+ expect(result.current.layer).toBe('symbols');
+ expect(result.current.shift).toBe('off');
+ });
+
+ it('types a space and reports backspace, enter and close', () => {
+ const onKey = vi.fn();
+ const onClose = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey, onClose }));
+ act(() => result.current.press({ type: 'space' }));
+ act(() => result.current.press({ type: 'backspace' }));
+ act(() => result.current.press({ type: 'enter' }));
+ act(() => result.current.press({ type: 'close' }));
+ expect(onKey.mock.calls).toEqual([
+ [{ type: 'text', value: ' ' }],
+ [{ type: 'backspace' }],
+ [{ type: 'enter' }],
+ ]);
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('falls back to the first layer when the layout changes', () => {
+ const other = { ...layout, layers: { only: layout.layers.symbols } };
+ const { result, rerender } = renderHook(
+ ({ current }) => useKeyboard({ layout: current, onKey: vi.fn() }),
+ {
+ initialProps: { current: layout },
+ },
+ );
+ act(() => result.current.press({ type: 'layer', to: 'symbols', label: '?123' }));
+ rerender({ current: other });
+ expect(result.current.layer).toBe('only');
+ });
+
+ it('describes keys for a screen reader', () => {
+ const { result } = renderHook(() => useKeyboard({ layout, onKey: vi.fn() }));
+ expect(result.current.describe({ type: 'text', value: ',' }).name).toBe('comma');
+ expect(result.current.describe({ type: 'text', value: '्' }).label).toBe('◌्');
+ expect(result.current.describe(shift)).toEqual({
+ label: '⇧',
+ caption: 'Shift',
+ name: 'Shift',
+ pressed: false,
+ });
+ expect(result.current.describe(caps)).toEqual({
+ label: '⇪',
+ caption: 'Caps',
+ name: 'Caps lock',
+ pressed: false,
+ });
+ });
+});
diff --git a/packages/example-react-package/src/css-modules.d.ts b/packages/saccade-keyboards/src/css-modules.d.ts
similarity index 100%
rename from packages/example-react-package/src/css-modules.d.ts
rename to packages/saccade-keyboards/src/css-modules.d.ts
diff --git a/packages/saccade-keyboards/src/index.js b/packages/saccade-keyboards/src/index.js
new file mode 100644
index 0000000..39e30aa
--- /dev/null
+++ b/packages/saccade-keyboards/src/index.js
@@ -0,0 +1,2 @@
+export { Keyboard } from './Keyboard.jsx';
+export { languages, loadLayout, registerLanguage } from './registry.js';
diff --git a/packages/saccade-keyboards/src/registry.js b/packages/saccade-keyboards/src/registry.js
new file mode 100644
index 0000000..fb63428
--- /dev/null
+++ b/packages/saccade-keyboards/src/registry.js
@@ -0,0 +1,54 @@
+import { validateLayout } from './core/layout.js';
+
+/** @typedef {import('./core/layout.js').Layout} Layout */
+
+/**
+ * Loads a layout: a function that imports it, such as `() => import('@coldsofttech/saccade-en')`.
+ * It resolves to the layout itself or to a module that exports it as `layout`.
+ *
+ * @typedef {() => Promise} LayoutLoader
+ */
+
+/** @type {Map} */
+const loaders = new Map();
+
+/**
+ * Makes a language available to `Keyboard` by its code.
+ *
+ * @param {string} id The language code, such as `en`.
+ * @param {LayoutLoader} loader Imports the layout when it is first needed.
+ */
+export function registerLanguage(id, loader) {
+ loaders.set(id, loader);
+}
+
+/**
+ * The language codes that can be loaded.
+ *
+ * @returns {string[]} The codes.
+ */
+export function languages() {
+ return [...loaders.keys()];
+}
+
+/**
+ * Loads and checks the layout of a language.
+ *
+ * @param {string} id The language code.
+ * @returns {Promise} The layout.
+ */
+export async function loadLayout(id) {
+ const loader = loaders.get(id);
+ if (!loader) {
+ throw new Error(
+ `No keyboard for "${id}". Install @coldsofttech/saccade-${id}, or add it with registerLanguage().`,
+ );
+ }
+ const loaded = await loader();
+ const layout = 'layout' in loaded ? loaded.layout : loaded;
+ const problems = validateLayout(layout);
+ if (problems.length > 0) {
+ throw new Error(`The keyboard for "${id}" is not a valid layout: ${problems.join('; ')}`);
+ }
+ return layout;
+}
diff --git a/packages/saccade-keyboards/tsconfig.build.json b/packages/saccade-keyboards/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-keyboards/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/example-react-package/vite.config.js b/packages/saccade-keyboards/vite.config.js
similarity index 89%
rename from packages/example-react-package/vite.config.js
rename to packages/saccade-keyboards/vite.config.js
index 47025d2..509d415 100644
--- a/packages/example-react-package/vite.config.js
+++ b/packages/saccade-keyboards/vite.config.js
@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
build: {
lib: {
- entry: 'src/index.js',
+ entry: { index: 'src/index.js', 'core/index': 'src/core/index.js' },
formats: ['es'],
},
sourcemap: true,
diff --git a/packages/saccade-te/README.md b/packages/saccade-te/README.md
new file mode 100644
index 0000000..687d45b
--- /dev/null
+++ b/packages/saccade-te/README.md
@@ -0,0 +1,30 @@
+# @coldsofttech/saccade-te
+
+The Telugu keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), in two layouts: **InScript**, the standard keyboard of India (consonants on the right hand, vowels on the left, Shift for the aspirated letters and conjuncts such as జ్ఞ and క్ష), and **phonetic**, where each key types the Telugu letter that sounds like the Latin letter on it. The phonetic keyboard has a second layer with the vowel signs (ా ి ీ ు ూ ృ ె ే ై ొ ో ౌ), candrabindu, anusvara, visarga and virama.
+
+The number row types Latin digits (0-9) with the Telugu digits (౦-౯) on AltGr, or the other way round.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-te
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('te', () => import('@coldsofttech/saccade-te'));
+
+ ;
+```
+
+## Phonetic and Telugu digits
+
+```jsx
+import { createLayout } from '@coldsofttech/saccade-te';
+
+ ;
+ ;
+```
+
+`createLayout(type, digits)` takes the keyboard, `'inscript'` (default) or `'phonetic'`, and the digits of the number row, `'latin'` (default) or `'telugu'`.
diff --git a/packages/saccade-te/package.json b/packages/saccade-te/package.json
new file mode 100644
index 0000000..6a4329c
--- /dev/null
+++ b/packages/saccade-te/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-te",
+ "version": "0.1.0",
+ "description": "Telugu keyboard layouts (InScript and phonetic) for @coldsofttech/saccade-keyboards.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-te"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-te/src/index.js b/packages/saccade-te/src/index.js
new file mode 100644
index 0000000..552bd0a
--- /dev/null
+++ b/packages/saccade-te/src/index.js
@@ -0,0 +1,230 @@
+import {
+ altGrKey,
+ backspaceKey,
+ bottomRow,
+ capsKey,
+ keys,
+ shiftKey,
+} from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+/** @typedef {Layout['layers'][string][number][number]} Key */
+/** @typedef {Extract} TextKey */
+/** @typedef {Extract} LayerKey */
+
+/**
+ * Which Telugu keyboard: `inscript`, the standard layout of India (consonants on the right hand,
+ * vowels on the left, shift for the aspirated ones), or `phonetic`, where each key types the Telugu
+ * letter that sounds like the Latin one on a QWERTY keyboard, with the vowel signs on their own
+ * layer.
+ *
+ * @typedef {'inscript' | 'phonetic'} Arrangement
+ */
+
+/**
+ * Which digits the number row types: `latin` (0-9) with the Telugu digits (౦-౯) on AltGr, or
+ * `telugu` with the Latin digits on AltGr.
+ *
+ * @typedef {'latin' | 'telugu'} Digits
+ */
+
+const LATIN = '0123456789';
+const TELUGU = '౦౧౨౩౪౫౬౭౮౯';
+
+/**
+ * A text key, with what it types with shift and after AltGr when it has them.
+ *
+ * @param {string} value
+ * @param {string} [shift]
+ * @param {string} [altgr]
+ * @returns {TextKey}
+ */
+const pair = (value, shift, altgr) => ({
+ type: 'text',
+ value,
+ ...(shift && { shift }),
+ ...(altgr && { altgr }),
+});
+
+/**
+ * Keys from space separated characters (a letter may be several characters, such as జ్ఞ) and the
+ * ones they type with shift, in the same order.
+ *
+ * @param {string} normal
+ * @param {string} [shifted]
+ * @returns {TextKey[]}
+ */
+const row = (normal, shifted = '') => {
+ const upper = shifted.split(' ');
+ return normal.split(' ').map((value, i) => pair(value, upper.at(i)));
+};
+
+/**
+ * The key of a digit: the digit in the chosen script, the other script on AltGr.
+ *
+ * @param {Digits} digits
+ * @param {number} n
+ * @param {string} shift
+ * @returns {TextKey}
+ */
+const digitKey = (digits, n, shift) =>
+ digits === 'telugu'
+ ? pair(TELUGU.at(n) ?? '', shift, LATIN.at(n))
+ : pair(LATIN.at(n) ?? '', shift, TELUGU.at(n));
+
+/**
+ * The four rows of an ANSI keyboard from the keys of each row.
+ *
+ * @param {TextKey[][]} rows
+ * @returns {Key[][]}
+ */
+const assemble = ([top, second, third, bottom]) => [
+ [...top, { ...backspaceKey, width: 2 }],
+ [...second.slice(0, -1), { .../** @type {TextKey} */ (second.at(-1)), width: 1.5 }],
+ [{ ...capsKey, width: 1.75 }, ...third, { type: 'enter', width: 2.25 }],
+ [{ ...shiftKey, width: 2.25 }, ...bottom, { ...shiftKey, width: 2.75 }],
+];
+
+/**
+ * The InScript keyboard: the signs sit on the number row, with Shift for the less common ones.
+ *
+ * @param {Digits} digits
+ * @returns {Key[][]}
+ */
+const inscript = (digits) =>
+ assemble([
+ [
+ pair('ొ', 'ఒ'),
+ digitKey(digits, 1, 'ఀ'),
+ digitKey(digits, 2, 'ౠ'),
+ digitKey(digits, 3, '్ర'),
+ digitKey(digits, 4, 'ర్'),
+ digitKey(digits, 5, 'జ్ఞ'),
+ digitKey(digits, 6, 'త్ర'),
+ digitKey(digits, 7, 'క్ష'),
+ digitKey(digits, 8, 'శ్ర'),
+ digitKey(digits, 9, '('),
+ digitKey(digits, 0, ')'),
+ pair('-', 'ః'),
+ pair('ృ', 'ఋ'),
+ ],
+ row('ౌ ై ా ీ ూ బ హ గ ద జ డ ౙ ౕ', 'ఔ ఐ ఆ ఈ ఊ భ ఙ ఘ ధ ఝ ఢ ఞ ౖ'),
+ row('ో ే ్ ి ు ప ర క త చ ట', 'ఓ ఏ అ ఇ ఉ ఫ ఱ ఖ థ ఛ ఠ'),
+ row('ె ం మ న వ ల స , . య', 'ఎ ఁ ణ ఽ ఴ ళ శ ష ఀ'),
+ ]);
+
+/**
+ * The phonetic keyboard: the Telugu letter that sounds like the Latin one, shift for its pair.
+ *
+ * @param {Digits} digits
+ * @returns {Key[][]}
+ */
+const phonetic = (digits) =>
+ assemble([
+ [
+ pair('`', '~'),
+ digitKey(digits, 1, '!'),
+ digitKey(digits, 2, '@'),
+ digitKey(digits, 3, '#'),
+ digitKey(digits, 4, '$'),
+ digitKey(digits, 5, '%'),
+ digitKey(digits, 6, '^'),
+ digitKey(digits, 7, '&'),
+ digitKey(digits, 8, '*'),
+ digitKey(digits, 9, '('),
+ digitKey(digits, 0, ')'),
+ pair('-', '_'),
+ pair('=', '+'),
+ ],
+ [...row('ట డ ఏ ర త య ఉ ఇ ఓ ప', 'ఠ ఢ ఐ ఋ థ ఞ ఊ ఈ ఔ ఫ'), ...keys('[]\\', '{}|')],
+ [...row('అ స ద ఫ గ హ జ క ల', 'ఆ శ ధ ఼ ఘ ః ఝ ఖ ళ'), ...keys(";'", ':"')],
+ [...row('ౙ క్ష చ వ బ న మ', 'ష జ్ఞ ఛ ఁ భ ణ ం'), pair(',', 'ఱ'), pair('.', 'ఴ'), pair('/', '?')],
+ ]);
+
+/**
+ * The vowel signs, the other signs and the conjuncts, which the phonetic letters do not have.
+ *
+ * @param {LayerKey} back
+ * @returns {Key[][]}
+ */
+const signs = (back) => [
+ row('ా ి ీ ు ూ ృ'),
+ row('ె ే ై ొ ో ౌ'),
+ row('ం ఁ ః ్ ఽ ఀ'),
+ [...row('ౕ ౖ ౄ ౘ త్ర శ్ర'), backspaceKey],
+ bottomRow(back),
+];
+
+/** @type {LayerKey} */
+const LETTERS_KEY = { type: 'layer', to: 'signs', label: 'గుణింతాలు', name: 'Vowel signs' };
+/** @type {LayerKey} */
+const BACK_KEY = { type: 'layer', to: 'letters', label: 'అక్షరాలు', name: 'Letters' };
+
+/** What a screen reader says for the signs, which have no sound of their own. */
+const NAMES = {
+ 'ా': 'ఆ గుణింతం',
+ 'ి': 'ఇ గుణింతం',
+ 'ీ': 'ఈ గుణింతం',
+ 'ు': 'ఉ గుణింతం',
+ 'ూ': 'ఊ గుణింతం',
+ 'ృ': 'ఋ గుణింతం',
+ 'ె': 'ఎ గుణింతం',
+ 'ే': 'ఏ గుణింతం',
+ 'ై': 'ఐ గుణింతం',
+ 'ొ': 'ఒ గుణింతం',
+ 'ో': 'ఓ గుణింతం',
+ 'ౌ': 'ఔ గుణింతం',
+ 'ౕ': 'ఏ పొడవు గుర్తు',
+ 'ౖ': 'ఐ పొడవు గుర్తు',
+ 'ం': 'సున్నా',
+ 'ఁ': 'అర్ధసున్నా',
+ 'ః': 'విసర్గ',
+ '్': 'విరామం',
+ '఼': 'నుక్తా',
+ ఽ: 'అవగ్రహం',
+ '₹': 'రూపాయి',
+};
+
+/**
+ * The Telugu layout in the given arrangement.
+ *
+ * @param {Arrangement} [type] The keyboard.
+ * @param {Digits} [digits] The digits of the number row.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'inscript', digits = 'latin') {
+ if (type !== 'inscript' && type !== 'phonetic') {
+ throw new Error(`Unknown arrangement "${type}". Use "inscript" or "phonetic".`);
+ }
+ if (digits !== 'latin' && digits !== 'telugu') {
+ throw new Error(`Unknown digits "${digits}". Use "latin" or "telugu".`);
+ }
+ const isInscript = type === 'inscript';
+ /** @type {Key[]} */
+ const bottom = [
+ { ...altGrKey, width: 1.5 },
+ ...(isInscript ? [] : [{ ...LETTERS_KEY, width: 1.5 }]),
+ ...keys('?!'),
+ { type: 'space', width: isInscript ? 5 : 3.5 },
+ ...keys(isInscript ? ':;' : ':…'),
+ { type: 'close', width: 1.5 },
+ ];
+ return {
+ id: 'te-IN',
+ name: isInscript ? 'Telugu (InScript)' : 'Telugu (phonetic)',
+ dir: 'ltr',
+ decimal: '.',
+ names: NAMES,
+ layers: {
+ letters: [...(isInscript ? inscript(digits) : phonetic(digits)), bottom],
+ ...(isInscript ? {} : { signs: signs(BACK_KEY) }),
+ },
+ };
+}
+
+/**
+ * The Telugu InScript layout with Latin digits, which is what `language="te"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-te/src/index.test.js b/packages/saccade-te/src/index.test.js
new file mode 100644
index 0000000..117a9d9
--- /dev/null
+++ b/packages/saccade-te/src/index.test.js
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const flat = (l, name = 'letters') => new Map(Object.entries(l.layers)).get(name).flat();
+const find = (l, value, name) => flat(l, name).find((key) => key.value === value);
+const typed = (l) =>
+ flat(l)
+ .filter((key) => key.type === 'text')
+ .flatMap((key) => [key.value, key.shift, key.altgr]);
+
+describe('saccade-te', () => {
+ it('is a valid InScript layout with Latin digits by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('te-IN');
+ expect(layout.name).toBe('Telugu (InScript)');
+ expect(find(layout, '1').altgr).toBe('౧');
+ });
+
+ it.each(['inscript', 'phonetic'])('is valid as %s with either digits', (type) => {
+ for (const digits of ['latin', 'telugu']) {
+ expect(validateLayout(createLayout(type, digits))).toEqual([]);
+ }
+ });
+
+ it('has the InScript consonants and vowels where the standard has them', () => {
+ expect(find(layout, 'బ').shift).toBe('భ');
+ expect(find(layout, 'ా').shift).toBe('ఆ');
+ expect(find(layout, 'క').shift).toBe('ఖ');
+ expect(find(layout, '్')).toBeDefined();
+ expect(find(layout, '5').shift).toBe('జ్ఞ');
+ });
+
+ it('types every Telugu letter and sign of InScript once as its own character', () => {
+ const all = typed(layout).filter((value) => /^[ఀ-౿]$/u.test(value ?? ''));
+ expect(all).toContain('ఙ');
+ expect(all).toContain('ఋ');
+ expect(all).toContain('ం');
+ expect(all).toContain('ఁ');
+ expect(all).toContain('ః');
+ });
+
+ it('swaps the digits with the other script on AltGr', () => {
+ const dev = createLayout('inscript', 'telugu');
+ expect(find(dev, '౧').altgr).toBe('1');
+ expect(find(dev, '౧').shift).toBe('ఀ');
+ expect(find(dev, '1')).toBeUndefined();
+ });
+
+ it('has a phonetic layer with the vowel signs behind a layer key', () => {
+ const phonetic = createLayout('phonetic');
+ expect(phonetic.name).toBe('Telugu (phonetic)');
+ expect(Object.keys(phonetic.layers)).toEqual(['letters', 'signs']);
+ expect(flat(phonetic).some((key) => key.type === 'layer' && key.to === 'signs')).toBe(true);
+ expect(find(phonetic, 'ా', 'signs')).toBeDefined();
+ expect(find(phonetic, '్', 'signs')).toBeDefined();
+ expect(
+ flat(phonetic, 'signs').some((key) => key.type === 'layer' && key.to === 'letters'),
+ ).toBe(true);
+ expect(find(phonetic, 'క').shift).toBe('ఖ');
+ expect(find(phonetic, 'బ').shift).toBe('భ');
+ });
+
+ it('names the signs for a screen reader', () => {
+ expect(layout.names['్']).toBe('విరామం');
+ });
+
+ it('rejects an unknown arrangement or digits', () => {
+ expect(() => createLayout('remington')).toThrow(/Unknown arrangement/);
+ expect(() => createLayout('inscript', 'roman')).toThrow(/Unknown digits/);
+ });
+});
diff --git a/packages/saccade-te/tsconfig.build.json b/packages/saccade-te/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-te/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/saccade-te/vite.config.js b/packages/saccade-te/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-te/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/packages/saccade-ur/README.md b/packages/saccade-ur/README.md
new file mode 100644
index 0000000..512a0b7
--- /dev/null
+++ b/packages/saccade-ur/README.md
@@ -0,0 +1,30 @@
+# @coldsofttech/saccade-ur
+
+The Urdu keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), written right to left, in two layouts: **standard**, the usual Urdu key map with a second letter on Shift (for example ا and آ, ت and ٹ), and **phonetic**, where each key types the Urdu letter that sounds like the Latin letter on it. Both have a second layer with the diacritics (zabar, zer, pesh, shadda, jazm) and Urdu punctuation.
+
+The number row types Latin digits (0-9) with the Urdu digits (۰-۹) on AltGr, or the other way round.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-ur
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('ur', () => import('@coldsofttech/saccade-ur'));
+
+ ;
+```
+
+## Phonetic and Urdu digits
+
+```jsx
+import { createLayout } from '@coldsofttech/saccade-ur';
+
+ ;
+ ;
+```
+
+`createLayout(type, digits)` takes the keyboard, `'standard'` (default) or `'phonetic'`, and the digits of the number row, `'latin'` (default) or `'urdu'`.
diff --git a/packages/saccade-ur/package.json b/packages/saccade-ur/package.json
new file mode 100644
index 0000000..494e693
--- /dev/null
+++ b/packages/saccade-ur/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-ur",
+ "version": "0.1.0",
+ "description": "Urdu keyboard layouts (standard and phonetic) for @coldsofttech/saccade-keyboards.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-ur"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-ur/src/index.js b/packages/saccade-ur/src/index.js
new file mode 100644
index 0000000..35478ad
--- /dev/null
+++ b/packages/saccade-ur/src/index.js
@@ -0,0 +1,221 @@
+import {
+ altGrKey,
+ backspaceKey,
+ bottomRow,
+ capsKey,
+ keys,
+ shiftKey,
+} from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+/** @typedef {Layout['layers'][string][number][number]} Key */
+/** @typedef {Extract} TextKey */
+/** @typedef {Extract} LayerKey */
+
+/**
+ * Which Urdu keyboard: `standard`, the usual Urdu key map (a second letter on Shift), or
+ * `phonetic`, where each key types the Urdu letter that sounds like the Latin one on a QWERTY
+ * keyboard. Both have the diacritics on their own layer.
+ *
+ * @typedef {'standard' | 'phonetic'} Arrangement
+ */
+
+/**
+ * Which digits the number row types: `latin` (0-9) with the Urdu digits (۰-۹) on AltGr, or
+ * `urdu` with the Latin digits on AltGr.
+ *
+ * @typedef {'latin' | 'urdu'} Digits
+ */
+
+const LATIN = '0123456789';
+const URDU = '۰۱۲۳۴۵۶۷۸۹';
+
+/**
+ * A text key, with what it types with shift and after AltGr when it has them.
+ *
+ * @param {string} value
+ * @param {string} [shift]
+ * @param {string} [altgr]
+ * @returns {TextKey}
+ */
+const pair = (value, shift, altgr) => ({
+ type: 'text',
+ value,
+ ...(shift && { shift }),
+ ...(altgr && { altgr }),
+});
+
+/**
+ * Keys from space separated characters (a letter may be several characters, such as a letter with a mark) and the
+ * ones they type with shift, in the same order.
+ *
+ * @param {string} normal
+ * @param {string} [shifted]
+ * @returns {TextKey[]}
+ */
+const row = (normal, shifted = '') => {
+ const upper = shifted.split(' ');
+ return normal.split(' ').map((value, i) => pair(value, upper.at(i)));
+};
+
+/**
+ * The key of a digit: the digit in the chosen script, the other script on AltGr.
+ *
+ * @param {Digits} digits
+ * @param {number} n
+ * @param {string} shift
+ * @returns {TextKey}
+ */
+const digitKey = (digits, n, shift) =>
+ digits === 'urdu'
+ ? pair(URDU.at(n) ?? '', shift, LATIN.at(n))
+ : pair(LATIN.at(n) ?? '', shift, URDU.at(n));
+
+/**
+ * The four rows of an ANSI keyboard from the keys of each row.
+ *
+ * @param {TextKey[][]} rows
+ * @returns {Key[][]}
+ */
+const assemble = ([top, second, third, bottom]) => [
+ [...top, { ...backspaceKey, width: 2 }],
+ [...second.slice(0, -1), { .../** @type {TextKey} */ (second.at(-1)), width: 1.5 }],
+ [{ ...capsKey, width: 1.75 }, ...third, { type: 'enter', width: 2.25 }],
+ [{ ...shiftKey, width: 2.25 }, ...bottom, { ...shiftKey, width: 2.75 }],
+];
+
+/**
+ * The number row both keyboards share.
+ *
+ * @param {Digits} digits
+ * @returns {TextKey[]}
+ */
+const numberRow = (digits) => [
+ pair('`', '~'),
+ digitKey(digits, 1, '!'),
+ digitKey(digits, 2, '@'),
+ digitKey(digits, 3, '#'),
+ digitKey(digits, 4, '$'),
+ digitKey(digits, 5, '٪'),
+ digitKey(digits, 6, '^'),
+ digitKey(digits, 7, '&'),
+ digitKey(digits, 8, '*'),
+ digitKey(digits, 9, '('),
+ digitKey(digits, 0, ')'),
+ pair('-', '_'),
+ pair('=', '+'),
+];
+
+/**
+ * The standard keyboard: the usual Urdu key map, with a second letter on Shift.
+ *
+ * @param {Digits} digits
+ * @returns {Key[][]}
+ */
+const standard = (digits) =>
+ assemble([
+ numberRow(digits),
+ [...row('ق و ع ر ت ے ء ی ہ پ', 'ط ؤ غ ڑ ٹ ۓ ئ ض ۃ ص'), ...keys('[]\\', '{}|')],
+ [...row('ا س د ف گ ح ج ک ل', 'آ ش ڈ ذ ژ خ چ ھ ث'), ...keys(";'", ':"')],
+ [...row('ز ں م ن ب ، ۔', 'ظ'), pair(',', '<'), pair('.', '>'), pair('/', '?')],
+ ]);
+
+/**
+ * The phonetic keyboard: the Urdu letter that sounds like the Latin one, Shift for its pair.
+ *
+ * @param {Digits} digits
+ * @returns {Key[][]}
+ */
+const phonetic = (digits) =>
+ assemble([
+ numberRow(digits),
+ [...row('ق ص ع ر ت ی ء ہ و پ', 'ط ض ے ڑ ٹ ئ ۓ ھ ؤ ف'), ...keys('[]\\', '{}|')],
+ [...row('ا س د ف گ ہ ج ک ل', 'آ ص ڈ ذ غ ح ژ خ ث'), ...keys(";'", ':"')],
+ [...row('ز ش چ ں ب ن م', 'ظ ض ط ذ ب ں ۃ'), pair('،', ','), pair('۔', '.'), pair('/', '?')],
+ ]);
+
+/**
+ * The diacritics, the Urdu punctuation and a few signs the letters do not have.
+ *
+ * @param {LayerKey} back
+ * @returns {Key[][]}
+ */
+const marks = (back) => [
+ row('َ ِ ُ ّ ْ ٰ'),
+ row('ً ٍ ٌ ٔ ؟ ؛'),
+ row('٪ ٫ ٬ ؍ ۔ ،'),
+ [...row('« » ﷲ'), backspaceKey],
+ bottomRow(back),
+];
+
+/** @type {LayerKey} */
+const MARKS_KEY = { type: 'layer', to: 'marks', label: 'اعراب', name: 'Diacritics' };
+/** @type {LayerKey} */
+const BACK_KEY = { type: 'layer', to: 'letters', label: 'حروف', name: 'Letters' };
+
+/** What a screen reader says for the signs, which have no sound of their own. */
+const NAMES = {
+ 'َ': 'زبر',
+ 'ِ': 'زیر',
+ 'ُ': 'پیش',
+ 'ّ': 'تشدید',
+ 'ْ': 'جزم',
+ 'ٰ': 'کھڑا زبر',
+ 'ً': 'تنوین زبر',
+ 'ٍ': 'تنوین زیر',
+ 'ٌ': 'تنوین پیش',
+ 'ٔ': 'ہمزہ',
+ '؟': 'سوالیہ نشان',
+ '؛': 'سیمی کولن',
+ '،': 'کوما',
+ '۔': 'ختمہ',
+ '٪': 'فیصد',
+ '٫': 'اعشاریہ',
+ '٬': 'ہزار کا نشان',
+ '«': 'کھلا اقتباس',
+ '»': 'بند اقتباس',
+ ﷲ: 'اللہ',
+};
+
+/**
+ * The Urdu layout in the given arrangement.
+ *
+ * @param {Arrangement} [type] The keyboard.
+ * @param {Digits} [digits] The digits of the number row.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'standard', digits = 'latin') {
+ if (type !== 'standard' && type !== 'phonetic') {
+ throw new Error(`Unknown arrangement "${type}". Use "standard" or "phonetic".`);
+ }
+ if (digits !== 'latin' && digits !== 'urdu') {
+ throw new Error(`Unknown digits "${digits}". Use "latin" or "urdu".`);
+ }
+ /** @type {Key[]} */
+ const bottom = [
+ { ...altGrKey, width: 1.5 },
+ { ...MARKS_KEY, width: 1.5 },
+ ...keys('؟!'),
+ { type: 'space', width: 3.5 },
+ ...keys(':;'),
+ { type: 'close', width: 1.5 },
+ ];
+ return {
+ id: 'ur-PK',
+ name: type === 'standard' ? 'Urdu (standard)' : 'Urdu (phonetic)',
+ dir: 'rtl',
+ decimal: '.',
+ names: NAMES,
+ layers: {
+ letters: [...(type === 'standard' ? standard(digits) : phonetic(digits)), bottom],
+ marks: marks(BACK_KEY),
+ },
+ };
+}
+
+/**
+ * The Urdu standard layout with Latin digits, which is what `language="ur"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-ur/src/index.test.js b/packages/saccade-ur/src/index.test.js
new file mode 100644
index 0000000..caab3dd
--- /dev/null
+++ b/packages/saccade-ur/src/index.test.js
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const LETTERS = [...'ابپتٹثجچحخدڈذرڑزژسشصضطظعغفقکگلمنوہھءیےںۃئۓؤآ'];
+
+const flat = (l, name = 'letters') => new Map(Object.entries(l.layers)).get(name).flat();
+const find = (l, value, name) => flat(l, name).find((key) => key.value === value);
+const typed = (l) =>
+ flat(l)
+ .filter((key) => key.type === 'text')
+ .flatMap((key) => [key.value, key.shift, key.altgr]);
+
+describe('saccade-ur', () => {
+ it('is a valid right to left standard layout with Latin digits by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('ur-PK');
+ expect(layout.name).toBe('Urdu (standard)');
+ expect(layout.dir).toBe('rtl');
+ expect(find(layout, '1').altgr).toBe('۱');
+ });
+
+ it.each(['standard', 'phonetic'])('is valid as %s with either digits', (type) => {
+ for (const digits of ['latin', 'urdu']) {
+ expect(validateLayout(createLayout(type, digits))).toEqual([]);
+ }
+ });
+
+ it.each(['standard', 'phonetic'])('can type every Urdu letter on the %s keyboard', (type) => {
+ const all = typed(createLayout(type));
+ expect(LETTERS.filter((letter) => !all.includes(letter))).toEqual([]);
+ });
+
+ it('puts a second letter on shift', () => {
+ expect(find(layout, 'ا').shift).toBe('آ');
+ expect(find(layout, 'ت').shift).toBe('ٹ');
+ });
+
+ it('swaps the digits with the other script on AltGr', () => {
+ const urdu = createLayout('standard', 'urdu');
+ expect(find(urdu, '۱').altgr).toBe('1');
+ expect(find(urdu, '۱').shift).toBe('!');
+ expect(find(urdu, '1')).toBeUndefined();
+ });
+
+ it('has a diacritics layer behind a layer key on both keyboards', () => {
+ for (const type of ['standard', 'phonetic']) {
+ const built = createLayout(type);
+ expect(Object.keys(built.layers)).toEqual(['letters', 'marks']);
+ expect(flat(built).some((key) => key.type === 'layer' && key.to === 'marks')).toBe(true);
+ expect(find(built, 'ّ', 'marks')).toBeDefined();
+ expect(find(built, 'َ', 'marks')).toBeDefined();
+ expect(flat(built, 'marks').some((key) => key.type === 'layer' && key.to === 'letters')).toBe(
+ true,
+ );
+ }
+ });
+
+ it('names the diacritics for a screen reader', () => {
+ expect(layout.names['ّ']).toBe('تشدید');
+ });
+
+ it('rejects an unknown arrangement or digits', () => {
+ expect(() => createLayout('remington')).toThrow(/Unknown arrangement/);
+ expect(() => createLayout('standard', 'roman')).toThrow(/Unknown digits/);
+ });
+});
diff --git a/packages/saccade-ur/tsconfig.build.json b/packages/saccade-ur/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-ur/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/saccade-ur/vite.config.js b/packages/saccade-ur/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-ur/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/scripts/README.md b/scripts/README.md
index 5426cc9..f870d67 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -1,26 +1,11 @@
# Scripts
-Helper scripts for maintaining the repository. They need Node 22 or newer and use no extra dependencies.
+Helper scripts for maintaining the repository. They need Node 22 or newer and use no extra dependencies. Packages run them from their `check:packages` script.
-## apply-repo-settings.js
+## check-no-react.js
-Applies the standard repository settings in one go: squash-only merging, delete branch on merge, secret scanning with push protection, Dependabot alerts and security updates, CodeQL default setup, the `skip-changelog` label and the `main` ruleset from `.github/rulesets/main.json`.
+Fails if a built package bundles React instead of treating it as a peer dependency.
-### Before you run it
+## check-pack.js
-- Install the [GitHub CLI](https://cli.github.com/) and sign in with `gh auth login`.
-- Use an account with admin rights on the repository.
-- CodeQL setup only works once the repository contains JavaScript, so run the script after adding code.
-
-### Run it
-
-From the repository root:
-
-```bash
-npm run repo:settings # the current repository
-npm run repo:settings -- owner/repo # a specific repository
-```
-
-Or call it directly: `node scripts/apply-repo-settings.js [owner/repo]`.
-
-It is safe to run again. If a ruleset named `main` already exists, it is left untouched. When it finishes, complete the manual steps in [docs/repo-setup.md](../docs/repo-setup.md).
+Fails if a package tarball contains anything other than build output and package metadata.
diff --git a/scripts/apply-repo-settings.js b/scripts/apply-repo-settings.js
deleted file mode 100644
index 5fdca5c..0000000
--- a/scripts/apply-repo-settings.js
+++ /dev/null
@@ -1,115 +0,0 @@
-#!/usr/bin/env node
-/**
- * Apply the standard security settings and the "main" ruleset to a repository.
- *
- * Usage: node scripts/apply-repo-settings.js [owner/repo] (defaults to the current repo)
- * Requires: gh CLI authenticated with admin rights on the repository.
- */
-import { spawnSync } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-
-const RULESET = fileURLToPath(new URL('../.github/rulesets/main.json', import.meta.url));
-
-/**
- * Runs `gh` and returns trimmed stdout. Exits the process on failure.
- *
- * @param {string[]} args Arguments for gh.
- * @param {unknown} [payload] JSON body sent on stdin.
- * @returns {string} Trimmed stdout.
- */
-function gh(args, payload) {
- const result = spawnSync('gh', args, {
- input: payload === undefined ? undefined : JSON.stringify(payload),
- encoding: 'utf8',
- });
- if (result.error || result.status !== 0) {
- const reason = result.error?.message ?? result.stderr.trim();
- console.error(`gh ${args.join(' ')} failed:\n${reason}`);
- process.exit(1);
- }
- return result.stdout.trim();
-}
-
-const repo =
- process.argv[2] ?? gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
-console.log(`Configuring ${repo}`);
-
-// Merge settings: squash only, delete head branches after merge.
-gh([
- 'api',
- '-X',
- 'PATCH',
- `repos/${repo}`,
- '-F',
- 'allow_squash_merge=true',
- '-F',
- 'allow_merge_commit=false',
- '-F',
- 'allow_rebase_merge=false',
- '-F',
- 'delete_branch_on_merge=true',
-]);
-
-// Secret protection (secret scanning + push protection).
-gh(['api', '-X', 'PATCH', `repos/${repo}`, '--input', '-'], {
- security_and_analysis: {
- secret_scanning: { status: 'enabled' },
- secret_scanning_push_protection: { status: 'enabled' },
- },
-});
-
-// Dependency graph is on by default for public repos.
-// Dependabot alerts and security updates.
-gh(['api', '-X', 'PUT', `repos/${repo}/vulnerability-alerts`]);
-gh(['api', '-X', 'PUT', `repos/${repo}/automated-security-fixes`]);
-
-// CodeQL default setup.
-gh([
- 'api',
- '-X',
- 'PATCH',
- `repos/${repo}/code-scanning/default-setup`,
- '-f',
- 'state=configured',
- '-f',
- 'query_suite=default',
-]);
-
-// Let the Init workflow open its pull request.
-gh([
- 'api',
- '-X',
- 'PUT',
- `repos/${repo}/actions/permissions/workflow`,
- '-f',
- 'default_workflow_permissions=read',
- '-F',
- 'can_approve_pull_request_reviews=true',
-]);
-
-// Label referenced by .github/release.yml (ignored if it already exists).
-spawnSync(
- 'gh',
- [
- 'label',
- 'create',
- 'skip-changelog',
- '--repo',
- repo,
- '--description',
- 'Exclude from release notes',
- '--color',
- 'ededed',
- ],
- { encoding: 'utf8' },
-);
-
-// Ruleset (skips creation if one named "main" already exists).
-const names = gh(['api', `repos/${repo}/rulesets`, '-q', '.[].name']).split('\n');
-if (names.includes('main')) {
- console.log("Ruleset 'main' already exists; skipping");
-} else {
- gh(['api', '-X', 'POST', `repos/${repo}/rulesets`, '--input', RULESET]);
-}
-
-console.log('Done. Complete the manual steps in docs/repo-setup.md.');
diff --git a/sweepito.toml b/sweepito.toml
index 55de16d..20aaa1f 100644
--- a/sweepito.toml
+++ b/sweepito.toml
@@ -3,6 +3,8 @@ profiles = ["node"]
risk = "safe"
[clean]
+# Also remove build output and coverage reports, which are not in the safe set by default.
+enable = ["node.dist", "node.coverage"]
# Exclusions always win over cleanup rules. Keep installed dependencies between commits.
exclude = ["node_modules/"]