From 0d979740419f81f3df104ef3ea3e900369bba2e7 Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Tue, 3 Feb 2026 16:51:53 +0200 Subject: [PATCH 1/3] Extend Keybinds to work with navigation and gamepad --- .../docs/components/Basic/keybinds.mdx | 185 +++++++++++++++++- .../docs/components/Utility/Navigation.mdx | 2 + package.json | 4 +- src/components/Basic/Keybinds/Keybind.tsx | 100 +++++++++- .../Basic/Keybinds/Keybinds.module.scss | 3 +- src/components/Basic/Keybinds/Keybinds.tsx | 56 ++++-- src/components/Basic/Keybinds/util/glyphs.ts | 54 +++++ src/components/Media/Icon/Icon.tsx | 2 +- .../actionMethods/actionMethods.types.ts | 10 + .../actionMethods/useActionMethods.ts | 33 ++++ .../areaMethods/areaMethods.types.ts | 2 +- .../Navigation/areaMethods/useAreaMethods.ts | 2 +- src/components/Utility/Navigation/types.ts | 3 +- tests/shared/keybinds/glyph-mappings.json | 12 ++ tests/shared/keybinds/keybinds-selectors.json | 3 +- tests/specs/keybinds.spec.js | 72 +++++++ .../src/components/Keybinds/KeybindsTest.tsx | 40 +++- 17 files changed, 554 insertions(+), 29 deletions(-) create mode 100644 src/components/Basic/Keybinds/util/glyphs.ts create mode 100644 tests/shared/keybinds/glyph-mappings.json diff --git a/docs/src/content/docs/components/Basic/keybinds.mdx b/docs/src/content/docs/components/Basic/keybinds.mdx index b82efc20..b27ac273 100644 --- a/docs/src/content/docs/components/Basic/keybinds.mdx +++ b/docs/src/content/docs/components/Basic/keybinds.mdx @@ -4,6 +4,8 @@ tableOfContents: maxHeadingLevel: 4 --- +import { Badge } from '@astrojs/starlight/components' + The `Keybinds` component is an easy way to set up keyboard + mouse binding logic in your UI. It works together with the `Keybind` component, which represents a single action slot. `Keybinds` provides context, conflict handling, and programmatic control. @@ -93,6 +95,8 @@ The `value` prop can be omitted since it will be seeded from `defaults`. | `ref` | `(ref: KeybindsRef) => void` | `undefined` | Exposes programmatic methods and the current bindings. | | `onConflict` | `(action: string, key: string \| null, conflictAction: string) => void` | `undefined` | Called when a conflict happens under any policy. | | `onChange` | `(prev: string \| null, next: string \| null, action: string) => void` | `undefined` | Called when a bind or unbind operation succeeds. | +| `mode` | `gamepad` \| `keyboard` | `keyboard` | Sets the mode for the input. Use `gamepad` in order to accept and display gamepad buttons. | +| `glyphOverrides` | `Partial \| JSX.Element>>` | `{}` | Object of custom display glyph overrides. Include only the gamepad values you want to change (e.g., `0`, `1`, `left.joystick`). [See Glyphs Object](#glyphs-object) | ### Ref API @@ -135,7 +139,138 @@ it starts to listen for the user to press a key or mouse button to bind the asso | Prop name | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `action` | `string` | `undefined` | The action that will have a key bound to it. | -| `value` | `string \| undefined` | `undefined` | The value associated with the keybind by default. If omitted, it will be null. The string provided must match the label from the [mappings object](#mappings-object), unless overridden. | +| `value` | `string \| undefined` | `undefined` | The value associated with the keybind by default. If omitted, it will be `null`. The string provided must match the label from the [mappings object](#mappings-object), unless overridden. If the `mode` prop is set to `gamepad` the value must match any of the values or aliases from the [Glyph Object](#glyphs-object) | +| `onAction` | `Record void>` | `undefined` | Allows you to add custom navigation action handlers to the keybind. See [Implemented Navigation Actions](#implemented-navigation-actions) for details. | +| `anchor` | `string \| HTMLElement` | `undefined` | Links navigation to another element. When the anchor element is focused, the button's actions will execute. Can be a CSS selector or HTMLElement. | + +### Implemented Navigation Actions + +The `Keybind` component implements the following navigation actions by default: + +| Action Name | Behavior | +|-------------|-----------------------------------| +| `select` | Begin listening for gamepad input | + +You can extend the `Keybind` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import Keybind from '@components/Basic/Keybinds/Keybind'; + + console.log('custom action')}}/> +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + +## Gamepad Support + +With the introduction of the [Navigation component](/components/utility/navigation/), the `Keybinds` component now supports gamepad input as well. + +To enable gamepad support, set the `mode` prop to `gamepad`. +When in gamepad mode, the component will listen for gamepad button presses instead of keyboard/mouse input. + +Internally, the gamepad buttons are identified and stored by the [Gamepad API standard button indices](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad/buttons), +while the axes are identified by custom aliases (e.g., `left.joystick.up`, `right.joystick.left`, etc.). +See the [Glyphs object](#glyphs-object) table for the full list of supported gamepad button codes and axis aliases. + +For easier readibility, you can use the same button codes and axis aliases used by the [Interaction Manager Gamepad Class](https://frontend-tools.coherent-labs.com/interaction-manager/features/gamepad/#options). +The will resolve to the correct button indices internally. + +### Usage + +In order for the `Keybind` component to listen for a gamepad button press, the `Keybind` component must be focused via the `Navigation` component system. The easiest way is to wrap the `Keybind` components with +a `Navigation.Area` component. + +```tsx +import Keybinds from '@components/Basic/Keybinds/Keybinds'; +import Keybind from '@components/Basic/Keybinds/Keybind'; +import Navigation from '@components/Utility/Navigation/Navigation'; + +const App = () => { + return ( + + + + + + + + + + ); +}; + +export default App; +``` + +:::caution[Duplicate bindings] +Binding a value of `xbox.a` and `face-button-down` or even `0` will result in the same button trying to be bound. In which case it will be treated as a conflict. +::: + +### Displayed glyphs + +When displaying the bound keys, the component will render the corresponding glyphs for the gamepad buttons. The default glpyhs are based on the standard Xbox controller layout and are displayed by the +[Icon](/components/media/icon/) component. + +You can override the default glyphs by providing a custom `glyphOverrides` prop to the `Keybinds` component and providing your own components or elements for the desired buttons. + +```tsx +import Keybinds from '@components/Basic/Keybinds/Keybinds'; +import Keybind from '@components/Basic/Keybinds/Keybind'; + +const App = () => { + const GLYPHS: GlyphOverrides = { + '0': Icon.gamepad.ps5.cross, + '1': Icon.gamepad.ps5.circle, + '2': , + '3': , + } + + return ( + + + + + + + + ); +}; + +export default App; +``` + +Now the `Keybind` component with `xbox.a` value will display the ps5 cross glyph instead of the default xbox a glyph. + +:::note +When overriding glyphs, make sure to use the exact same gamepad button codes and axis aliases as specified in the [Glyphs object](#glyphs-object) table. +::: + +### Styling the gamepad glyphs + +If you wish to provide custom styling to the displayed glyphs such as changing the `wdith/height` of the displayed image, +you can do so by overriding the glyph via the `glyphOverrides` prop and providing your own component or element with the desired styles. + +For example, to keep the default xbox glyphs but change their size, you can do the following: + +```css title="index.css" +.gamepad-glyph { + width: 5vmax; + height: 5vmax; +} +``` + +```tsx +const GLYPHS: GlyphOverrides = { + '0': , + '1': , + '2': , + '3': , +} +``` + +:::tip +We recommend using the [Icon](/components/media/icon/) component for rendering the glyphs. +::: ## Guide @@ -545,4 +680,50 @@ The object is located at `src/components/Basic/Keybinds/mappings.ts`. | Code | Label | |-------------------|----------------| | WheelUp | Wheel Up | -| WheelDown | Wheel Down | \ No newline at end of file +| WheelDown | Wheel Down | + +### Glyphs object + +The `Keybinds` component comes with a default glyphs object that maps gamepad button codes to their corresponding glyph components for display purposes. + +The glyphs object is located at `src/components/Basic/Keybinds/glyphs.ts`. + +You can override the glyphs you want to display custom `Components` or `HTMLElements` by providing a custom `glyphOverrides` prop to the `Keybinds` component. +Keep in mind that you should use the exact same gamepad button codes and axis aliases as specified below. + +#### Buttons (Indices) + +| Code | Standard Xbox | Standard PlayStation | +|------|---------------|----------------------| +| 0 | A | Cross | +| 1 | B | Circle | +| 2 | X | Square | +| 3 | Y | Triangle | +| 4 | LB (Bumper) | L1 | +| 5 | RB (Bumper) | R1 | +| 6 | LT (Trigger) | L2 | +| 7 | RT (Trigger) | R2 | +| 8 | View (Back) | Create/Share | +| 9 | Menu (Start) | Options | +| 10 | Left Stick Press (L3) | L3 | +| 11 | Right Stick Press (R3) | R3 | +| 12 | D-Pad Up | D-Pad Up | +| 13 | D-Pad Down | D-Pad Down | +| 14 | D-Pad Left | D-Pad Left | +| 15 | D-Pad Right | D-Pad Right | +| 16 | Guide / Share | PS Button | + +#### Joysticks aliases + +| Code | Description | +| :--- | :--- | +| `left.joystick` | The entire Left Analog Stick | +| `left.joystick.up` | Left Stick Up direction | +| `left.joystick.down` | Left Stick Down direction | +| `left.joystick.left` | Left Stick Left direction | +| `left.joystick.right` | Left Stick Right direction | +| `right.joystick` | The entire Right Analog Stick | +| `right.joystick.up` | Right Stick Up direction | +| `right.joystick.down` | Right Stick Down direction | +| `right.joystick.left` | Right Stick Left direction | +| `right.joystick.right` | Right Stick Right direction | \ No newline at end of file diff --git a/docs/src/content/docs/components/Utility/Navigation.mdx b/docs/src/content/docs/components/Utility/Navigation.mdx index 3ca123a2..ca0ae44a 100644 --- a/docs/src/content/docs/components/Utility/Navigation.mdx +++ b/docs/src/content/docs/components/Utility/Navigation.mdx @@ -383,6 +383,8 @@ The `Navigation` component exposes a comprehensive API through the `useNavigatio | `getScope` | None | `string` | Gets the current navigation scope (typically the name of the active navigation area). | | `getAction` | `name: ActionName` | `ActionCfg \| undefined` | Gets a specific action configuration by name. Returns undefined if the action doesn't exist. | | `getActions` | None | `ActionMap` | Gets all currently registered actions. | +| `pauseInput` | None | `void` | Snapshots current pause states and forcefully pauses all actions (ideal for stopping all action input). `resumeInput` must be used to unpause all actions. | +| `resumeInput` | None | `void` | Releases the global pause and restores actions to their state prior to the `pauseInput` call. | ### Area Methods diff --git a/package.json b/package.json index 3fc88854..713620ec 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "dependencies": { "@solid-primitives/jsx-tokenizer": "^1.1.1", "@types/node": "^22.9.0", - "coherent-gameface-interaction-manager": "^2.6.0", + "coherent-gameface-interaction-manager": "^2.8.0", "cors-env": "^1.0.2", "dotenv": "^17.2.0", "glob": "^11.0.0", @@ -40,4 +40,4 @@ "vite-gameface": "^1.0.3", "vite-solid-style-to-css": "^1.0.1" } -} \ No newline at end of file +} diff --git a/src/components/Basic/Keybinds/Keybind.tsx b/src/components/Basic/Keybinds/Keybind.tsx index 1a2283d8..6fff00d3 100644 --- a/src/components/Basic/Keybinds/Keybind.tsx +++ b/src/components/Basic/Keybinds/Keybind.tsx @@ -1,13 +1,17 @@ -import { createMemo, createSignal, onMount, useContext } from "solid-js"; -import { KeybindsContext } from "./Keybinds"; +import { createMemo, createSignal, JSX, onMount, useContext } from "solid-js"; +import { KeybindsContext, KeyCode } from "./Keybinds"; import { ComponentProps } from "@components/types/ComponentProps"; import useBaseComponent from "@components/BaseComponent/BaseComponent"; import style from './Keybinds.module.scss' import { BindingCode, BindingLabel } from "./util/mappings"; +import { gamepad, GamepadInput, GamepadMappings } from "coherent-gameface-interaction-manager"; +import { GamepadBindingCode } from "./util/glyphs"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; +import { useNavigation } from "@components/Utility/Navigation/Navigation"; interface KeyBindProps extends ComponentProps { action: string, - value?: BindingLabel | (string & {}), + value?: BindingLabel | GamepadInput | (string & {}), } const Keybind = (props: KeyBindProps) => { @@ -19,15 +23,29 @@ const Keybind = (props: KeyBindProps) => { const [listening, setListening] = createSignal(false); let el!: HTMLDivElement; + const nav = useNavigation(); const label = createMemo(() => { if (listening()) return context.listeningText?.() ?? 'Press any key...'; + + const currentValue = context.bindings[props.action]; + if (!currentValue) return context.placeholder?.() ?? ''; + + if (context.mode === 'gamepad') { + const DisplayValue = context.GLYPHS[currentValue as GamepadBindingCode]; + if (typeof DisplayValue === 'function') { + return DisplayValue({}); + } + return DisplayValue as JSX.Element; + } - return context.bindings[props.action] || (context.placeholder?.() ?? ''); + return currentValue; }) const startListening = () => { - setListening(true); + if (context.mode === 'gamepad') return; + + setListening(true) el.focus(); window.addEventListener("keydown", onKeyDown, true); window.addEventListener("mousedown", onMousedown, true); @@ -43,6 +61,75 @@ const Keybind = (props: KeyBindProps) => { setListening(false); }; + const startListeningGamepad = () => { + setListening(true); + // pause all actions + nav?.pauseInput(); + + let isFinished = false; + // axis callbacks references + const registeredCallbacks: Record void> = {}; + + const stopAllListeners = () => { + clearInterval(pollInterval); + // remove axes callbacks + for (const alias in registeredCallbacks) { + gamepad.off([alias as GamepadInput], registeredCallbacks[alias]); + } + }; + + const bindAndCleanup = (inputCode: KeyCode, isButton: boolean = false, pressedBtnObj?: GamepadButton) => { + if (isFinished) return; + isFinished = true; + + stopAllListeners(); + + const prevKey = context.bindings[props.action] || null; + const success = context.bind(props.action, inputCode); + + if (success) context.onChange?.(prevKey, String(inputCode), props.action); + setListening(false); + + if (isButton && pressedBtnObj) { + const releaseInterval = setInterval(() => { + if (!pressedBtnObj.pressed) { + clearInterval(releaseInterval); + nav?.resumeInput(); + } + }, 500); + } else { + nav?.resumeInput(); + } + }; + + // Stick logic + GamepadMappings.axisAliases.forEach((alias, index) => { + if (index <= 1) return; + // Create the specific reference + const callback = () => bindAndCleanup(alias as KeyCode); + // Save it for later + registeredCallbacks[alias] = callback; + // Register it + gamepad.on({ actions: [alias], callback }); + }); + + // Button logic + const pollInterval = setInterval(() => { + const gamepad = navigator.getGamepads()[0]; + if (!gamepad || isFinished) return; + + let buttonIdx = -1; + const pressedButton = gamepad?.buttons.find((btn, idx) => { + if (btn.pressed) { + buttonIdx = idx; + return btn + } + }) + + if (pressedButton) bindAndCleanup(buttonIdx as any as KeyCode, true, pressedButton); + }, 150) + } + const eatEvent = (e :Event) => { e.preventDefault(); e.stopPropagation(); @@ -80,7 +167,7 @@ const Keybind = (props: KeyBindProps) => { }) props.componentClasses = style.keybind; - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); return (
{ style={inlineStyles()} use:forwardEvents={props} use:forwardAttrs={props} + use:navigationActions={mergeNavigationActions(props, {'select': startListeningGamepad})} onmouseup={startListening}> {label()}
diff --git a/src/components/Basic/Keybinds/Keybinds.module.scss b/src/components/Basic/Keybinds/Keybinds.module.scss index 57b753d4..7ab3a0e2 100644 --- a/src/components/Basic/Keybinds/Keybinds.module.scss +++ b/src/components/Basic/Keybinds/Keybinds.module.scss @@ -8,7 +8,8 @@ align-items: center; justify-content: center; - &:hover { + &:hover, + &:focus { border-color: rgba($primaryColor, 0.75); } } \ No newline at end of file diff --git a/src/components/Basic/Keybinds/Keybinds.tsx b/src/components/Basic/Keybinds/Keybinds.tsx index 13c68a5b..cf2d0a94 100644 --- a/src/components/Basic/Keybinds/Keybinds.tsx +++ b/src/components/Basic/Keybinds/Keybinds.tsx @@ -2,11 +2,14 @@ import { BaseComponentRef } from "@components/types/ComponentProps"; import { Accessor, batch, createContext, createEffect, createMemo, on, onMount, ParentComponent } from "solid-js"; import { createStore, unwrap } from "solid-js/store"; import {BindingCode, BindingLabel, MAPPINGS} from "./util/mappings"; +import { GlyphOverrides } from "./util/glyphs"; +import { GLYPHS as DEFAULT_GLYPHS } from "./util/glyphs"; +import { AxisInput, GamepadInput, GamepadMappings } from "coherent-gameface-interaction-manager"; export type Action = string; -export type KeyCode = BindingLabel | (string & {}) | null; +export type KeyCode = BindingLabel | GamepadInput | (string & {}) | null; export type ConflictPolicy = 'block' | 'replace-existing' | 'swap' | 'allow-duplicates' -type Bindings = Record; +export type Bindings = Record; export interface KeybindsRef { bindings: Bindings, @@ -17,32 +20,38 @@ export interface KeybindsRef { reset: () => void, } -interface KeybindsContext { +interface SharedProps { + onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void, + mode?: 'gamepad' | 'keyboard' +} + +interface KeybindsContext extends SharedProps { KEYS: Record, + GLYPHS: GlyphOverrides, bindings: Bindings, bind: (action: Action, newKey: KeyCode) => boolean, placeholder?: Accessor, listeningText?: Accessor, - useChars?: Accessor - onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void, + useChars?: Accessor, } export const KeybindsContext = createContext(); -interface KeybindsProps { +interface KeybindsProps extends SharedProps { defaults?: Bindings, overrides?: Partial>, placeholder?: string, listeningText?: string, conflictPolicy?: ConflictPolicy, + glyphOverrides?: GlyphOverrides; ref?: unknown | ((ref: BaseComponentRef) => void); onConflict?: (action: Action, key: KeyCode, conflictAction: Action) => void, - onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void, } const Keybinds: ParentComponent = (props) => { const KEYS = {...MAPPINGS, ...props.overrides}; // { event.code: label } const DISPLAY_LABELS = new Set(Object.values(KEYS)); // Set of valid labels only + const GLYPHS = {... DEFAULT_GLYPHS, ...props.glyphOverrides }; const [bindings, setBindings] = createStore({}); let defaults = props.defaults ?? undefined; @@ -55,6 +64,26 @@ const Keybinds: ParentComponent = (props) => { mapBindings({...bindings}) }, {defer: true})); + const sanitizeButtonInput = (input: string | number) => { + const num = Number(input); + if (!isNaN(num) || typeof input === 'number') { + if (0 <= num && num <= 16) return String(input); + else { + console.warn(`${input} is not a valid gamepad button. Please ensure the number is between 0 and 16`); + return null + } + } + + const button = input.toLowerCase(); + if ((GamepadMappings.axisAliases as readonly string[]).includes(button)) return button as AxisInput; + + const key = GamepadMappings.aliases[button as keyof typeof GamepadMappings.aliases]; + if (key) return String(GamepadMappings[key]); + + console.warn(`${input} is not a valid gamepad button.`); + return null; + } + const verifyKey = (key: string | null) => { if (key === null) return null; if (DISPLAY_LABELS.has(key)) return key; @@ -78,14 +107,17 @@ const Keybinds: ParentComponent = (props) => { const prevKey = bindings[action]; if (prevKey === newKey) return false; + newKey = props.mode === 'gamepad' + ? sanitizeButtonInput(newKey as GamepadInput) + : verifyKey(newKey as BindingLabel); + const conflictAction = byKey.get(newKey); // no conflict -> just bind if (!conflictAction || conflictAction === action) { if (prevKey) byKey.delete(prevKey); - const verifiedKey = verifyKey(newKey); - setBindings(action, verifiedKey); - if (verifiedKey !== null) byKey.set(newKey, action); + setBindings(action, newKey); + if (newKey !== null) byKey.set(newKey, action); return true } @@ -175,7 +207,9 @@ const Keybinds: ParentComponent = (props) => { listeningText, placeholder, KEYS, - onChange: props.onChange + GLYPHS, + onChange: props.onChange, + mode: props.mode } return ( diff --git a/src/components/Basic/Keybinds/util/glyphs.ts b/src/components/Basic/Keybinds/util/glyphs.ts new file mode 100644 index 00000000..3033000c --- /dev/null +++ b/src/components/Basic/Keybinds/util/glyphs.ts @@ -0,0 +1,54 @@ +import { Icon } from "@components/Media/Icon/Icon"; +import { GamepadMappings } from "coherent-gameface-interaction-manager"; +import { Component, JSX } from "solid-js"; + +/** + * Maps standard Gamepad API button indices to Gameface-ui Icon components. + * [Reference](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API#button_layout) + */ +export const GLYPHS = { + // Face Buttons + '0': Icon.gamepad.xbox.a, + '1': Icon.gamepad.xbox.b, + '2': Icon.gamepad.xbox.x, + '3': Icon.gamepad.xbox.y, + + // Shoulder Buttons (Bumpers) + '4': Icon.gamepad.xbox.lb, + '5': Icon.gamepad.xbox.rb, + + // Triggers + '6': Icon.gamepad.xbox.lt, + '7': Icon.gamepad.xbox.rt, + + // Navigation / Center Buttons + '8': Icon.gamepad.xbox.view, // Often used as 'Back' or 'Select' + '9': Icon.gamepad.xbox.menu, // Often used as 'Start' + + // Stick Presses (L3 / R3) + '10': Icon.gamepad.xbox.leftStickPress, + '11': Icon.gamepad.xbox.rightStickPress, + + // D-Pad + '12': Icon.gamepad.xbox.dpadUp, + '13': Icon.gamepad.xbox.dpadDown, + '14': Icon.gamepad.xbox.dpadLeft, + '15': Icon.gamepad.xbox.dpadRight, + + // Additional Button (Xbox Guide/Share) + '16': Icon.gamepad.xbox.share, + + 'right.joystick': Icon.gamepad.xbox.rightStick, + 'left.joystick': Icon.gamepad.xbox.leftStick, + 'left.joystick.down': Icon.gamepad.xbox.leftStick, + 'left.joystick.up': Icon.gamepad.xbox.leftStick, + 'left.joystick.left': Icon.gamepad.xbox.leftStick, + 'left.joystick.right': Icon.gamepad.xbox.leftStick, + 'right.joystick.down': Icon.gamepad.xbox.rightStick, + 'right.joystick.up': Icon.gamepad.xbox.rightStick, + 'right.joystick.left': Icon.gamepad.xbox.rightStick, + 'right.joystick.right': Icon.gamepad.xbox.rightStick +}; + +export type GamepadBindingCode = keyof typeof GLYPHS; +export type GlyphOverrides = Partial | JSX.Element>>; \ No newline at end of file diff --git a/src/components/Media/Icon/Icon.tsx b/src/components/Media/Icon/Icon.tsx index 3e14b78a..f707f607 100644 --- a/src/components/Media/Icon/Icon.tsx +++ b/src/components/Media/Icon/Icon.tsx @@ -30,7 +30,7 @@ const IconComponent = (src: string): Component => { {...props} use:forwardAttrs={props} src={src} - class={`${styles.icon} ${props.class || ''}`} + class={`${styles.icon} ${props?.class || ''}`} onError={() => setHasError(true)} /> )} diff --git a/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts b/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts index ab91f2ea..88c5bffd 100644 --- a/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts +++ b/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts @@ -83,6 +83,16 @@ export interface ActionMethods { */ resumeAction: (action: ActionName, force?: boolean) => void; + /** + * Snapshots current pause states and forcefully pauses all actions (ideal for stopping all action input). `resumeInput` must be used to unpause all actions. + */ + pauseInput: () => void; + + /** + * Releases the global pause and restores actions to their state prior to the pauseInput call. + */ + resumeInput: () => void; + /** * Checks if an action is currently paused * @param action - The name of the action to check diff --git a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts index 3a2ce2a0..e1ef3f70 100644 --- a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts @@ -13,6 +13,8 @@ export default function createActionMethods( const actionSubscribers = new Map(); // For force paused actions const forcePausedActions = new Set(); + // For renenbering which already paused actions were affected in the pause input function + const inputCaptureSnapshot = new Set(); const registerAction = (actionName: ActionName) => { const {key, button, callback, global} = getAction(actionName)!; @@ -156,6 +158,35 @@ export default function createActionMethods( return getAction(name)?.paused ?? false; }; + const pauseInput = () => { + const allActions = getActions(); + inputCaptureSnapshot.clear(); + + for (const actionName in allActions) { + if (isPaused(actionName)) { + inputCaptureSnapshot.add(actionName); + } + + pauseAction(actionName, true); + } + }; + + const resumeInput = () => { + const allActions = getActions(); + + for (const actionName in allActions) { + if (inputCaptureSnapshot.has(actionName)) { + // remove the force flag only + forcePausedActions.delete(actionName); + continue; + } + + resumeAction(actionName, true); + } + + inputCaptureSnapshot.clear(); + }; + return { addAction, removeAction, @@ -168,6 +199,8 @@ export default function createActionMethods( getActions, pauseAction, resumeAction, + pauseInput, + resumeInput, isPaused, }; } diff --git a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts index 35d5a59d..a69275b5 100644 --- a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts +++ b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts @@ -1,4 +1,4 @@ -import { KeyName } from "coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings"; +import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager'; /** * Interface defining all area-related navigation methods diff --git a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts index 2b51adc2..bdd5536e 100644 --- a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts +++ b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts @@ -3,7 +3,7 @@ import { SetStoreFunction } from 'solid-js/store'; import { NavigationConfigType } from '../types'; import { AreaMethods } from './areaMethods.types'; import { spatialNavigation } from 'coherent-gameface-interaction-manager'; -import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings'; +import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager'; export default function createAreaMethods( areas: Set, diff --git a/src/components/Utility/Navigation/types.ts b/src/components/Utility/Navigation/types.ts index de54d069..4dd98b09 100644 --- a/src/components/Utility/Navigation/types.ts +++ b/src/components/Utility/Navigation/types.ts @@ -1,5 +1,4 @@ -import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings'; -import { GamepadInput } from 'coherent-gameface-interaction-manager/dist/types/utils/gamepad-mappings'; +import { KeyName, GamepadInput } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager'; type ActionType = 'press' | 'hold' | 'lift'; diff --git a/tests/shared/keybinds/glyph-mappings.json b/tests/shared/keybinds/glyph-mappings.json new file mode 100644 index 00000000..db47644b --- /dev/null +++ b/tests/shared/keybinds/glyph-mappings.json @@ -0,0 +1,12 @@ +[ + { "action": "moveForward", "key": "12", "glyph": "dpadUp" }, + { "action": "moveBackward", "key": "13", "glyph": "dpadDown" }, + { "action": "moveLeft", "key": "14", "glyph": "dpadLeft" }, + { "action": "moveRight", "key": "15", "glyph": "dpadRight" }, + { "action": "jump", "key": "0", "glyph": "a" }, + { "action": "crouch", "key": "2", "glyph": "x" }, + { "action": "sprint", "key": "10", "glyph": "leftStickPress" }, + { "action": "fire", "key": "7", "glyph": "rt" }, + { "action": "aimDownSights","key": "6", "glyph": "lt" }, + { "action": "reload", "key": "1", "glyph": "b" } +] \ No newline at end of file diff --git a/tests/shared/keybinds/keybinds-selectors.json b/tests/shared/keybinds/keybinds-selectors.json index 02c76895..2e44f797 100644 --- a/tests/shared/keybinds/keybinds-selectors.json +++ b/tests/shared/keybinds/keybinds-selectors.json @@ -5,5 +5,6 @@ "keybind": "keybind", "listening-text": "Please type", "placeholder-text": "Unassigned", - "reactive": "reactive" + "reactive": "reactive", + "actionTest": "action-test-element" } \ No newline at end of file diff --git a/tests/specs/keybinds.spec.js b/tests/specs/keybinds.spec.js index 0743a4e8..5a265dd7 100644 --- a/tests/specs/keybinds.spec.js +++ b/tests/specs/keybinds.spec.js @@ -2,6 +2,7 @@ const assert = require('assert'); const selectors = require('../shared/keybinds/keybinds-selectors.json'); const DEFAULT_MAPPINGS = require('../shared/keybinds/default-mappings.json'); const ALTERNATE_MAPPINGS = require('../shared/keybinds/alternate-mappings.json'); +const GAMEPAD_GLYPHS = require ("../shared/keybinds/glyph-mappings.json"); const OVERRIDES = require('../shared/keybinds/overrides.json'); const { navigateToPage } = require('../shared/utils'); @@ -256,4 +257,75 @@ describe('Keybinds', function () { assert.strictEqual(await keybinds[1].text(), keybind2InitialText, `Keybind should have its initial value`); }); }) + + describe('Keybinds - gamepad mode', function () { + this.beforeAll(async () => { + await gf.connectGamepad('gamepad') + await gf.click(`.${selectors.scenarioBtn}.scenario-12`) + }) + + async function getIconSource (keybind) { + const image = await keybind.find('img'); + return await image.getAttribute('src'); + } + + it('Should render default gamepad glyphs', async () => { + const container = await gf.getAll(`.${selectors.keybindsContainer}`); + const keybinds = await container[2].findAll(`.${selectors.keybind}`); + + let idx = 0; + for (const keybind of keybinds) { + const src = await getIconSource(keybind); + + assert.equal(src.includes(`/${GAMEPAD_GLYPHS[idx].glyph}`), true, 'Correct icon is displayed'); + idx++; + } + }); + + it('Should rebind a gamepad input', async () => { + const container = await gf.getAll(`.${selectors.keybindsContainer}`); + const keybind = await container[2].find(`.${selectors.keybind}`); + const gamepad = gf.getGamepad('gamepad') + + await gamepad.sequence([ + gf.GAMEPAD_BUTTONS.FACE_BUTTON_DOWN, + gf.GAMEPAD_BUTTONS.FACE_BUTTON_RIGHT, + ]); + + const src = await getIconSource(keybind); + assert.equal(src.includes(`/b`), true, 'Correct icon is displayed'); + }) + + it('Should prevent exection of any actions while listening for input', async () => { + const assertionEl = await gf.get(`.${selectors.actionTest}`) + const gamepad = gf.getGamepad('gamepad') + + await gamepad.sequence([ + gf.GAMEPAD_BUTTONS.FACE_BUTTON_DOWN, + gf.GAMEPAD_BUTTONS.RIGHT_SHOULDER, + ]); + + assert.equal(await assertionEl.text(), 'test', 'Element\'s text should remain unchanged after action is bound') + }) + + it('Should render custom glyphs', async () => { + const container = await gf.getAll(`.${selectors.keybindsContainer}`); + const keybinds = await container[3].findAll(`.${selectors.keybind}`); + + const NEW_GLYPHS_TEXT = ['up', 'down', 'left', 'right']; + let idx = 0; + + for (const keybind of keybinds) { + if (idx === NEW_GLYPHS_TEXT.length) break; + + const child = await keybind.find('div'); + assert.equal(await child.text(), NEW_GLYPHS_TEXT[idx], 'Glyph text content should match structure.'); + if (idx === NEW_GLYPHS_TEXT.length - 1) { + const styles = await child.styles(); + assert.equal(styles['background-color'], 'rgba(0, 0, 255, 1)', 'Styles of glyphs changed'); + } + idx++; + } + }) + }) }); \ No newline at end of file diff --git a/tests/src/components/Keybinds/KeybindsTest.tsx b/tests/src/components/Keybinds/KeybindsTest.tsx index 173d5d58..03b5f4f9 100644 --- a/tests/src/components/Keybinds/KeybindsTest.tsx +++ b/tests/src/components/Keybinds/KeybindsTest.tsx @@ -1,22 +1,26 @@ import Tab from "@components/Layout/Tab/Tab"; -import { createMemo, createSignal, For, Match, onCleanup, onMount, Switch } from "solid-js"; +import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"; import selectors from "../../../shared/keybinds/keybinds-selectors.json"; import Keybinds, { KeybindsRef } from "@components/Basic/Keybinds/Keybinds"; import Keybind from "@components/Basic/Keybinds/Keybind"; import TEST_KEYS from '../../../shared/keybinds/default-mappings.json'; import ALTERNATE_KEYS from "../../../shared/keybinds/alternate-mappings.json"; +import GAMEPAD_GLYPHS from "../../../shared/keybinds/glyph-mappings.json"; import OVERRIDES from "../../../shared/keybinds/overrides.json"; import Flex from "@components/Layout/Flex/Flex"; +import Navigation, { NavigationRef } from "@components/Utility/Navigation/Navigation"; const KeybindsTest = () => { let keybindsRef!: KeybindsRef; let keybindsRefWithDefault!: KeybindsRef; + let nav!: NavigationRef; const [test, setTest] = createSignal('red'); const [lastChanged, setLastChanged] = createSignal({ prev: "", next: "", action: ""}); const [hasConflict, setHasConflict] = createSignal(false); const [policy, setPolicy] = createSignal<'block' | 'replace-existing' | 'allow-duplicates' | 'swap'>('block') const [listeningText, setListeningText] = createSignal(undefined) const [placeholder, setPlaceholder] = createSignal('Unbound') + const [actionTest, setActionTest] = createSignal('test'); const scenarios = [ { label: "Change policy - replace-existing", action: () => { setPolicy('replace-existing') } }, @@ -31,6 +35,7 @@ const KeybindsTest = () => { { label: "Change placeholder text", action: () => { setPlaceholder(selectors["placeholder-text"]) } }, { label: "Change styles", action: () => { setTest('blue') } }, { label: "Reset defaults - alternate", action: () => { keybindsRefWithDefault.reset() } }, + { label: "Focus keybind nav area", action: () => { nav.focusFirst('keybinds') } }, ]; const reset = () => { @@ -43,6 +48,8 @@ const KeybindsTest = () => { keybindsRef?.reset(); }; + const testComponent = () =>
down
; + const isReactive = createMemo(() => test() === 'blue'); const reactiveClass = createMemo(() => isReactive() ? 'reactive' : ''); const reactiveStyle = createMemo(() => isReactive() ? { 'background-color': 'blue' } : {}); @@ -94,6 +101,37 @@ const KeybindsTest = () => { + + {/* Gamepad support */} + setActionTest('changed')} }}> + + + + + {(entry) => } + + + +
{actionTest()}
+
+ + up, + '13': testComponent, + '14': () =>
left
, + '15': () =>
right
+ }}> + + {(entry) => } + +
+
+
) } From 785da8dde90dedfedbd983951a8d266fffaa4b6a Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Tue, 3 Feb 2026 16:58:02 +0200 Subject: [PATCH 2/3] Add changelog entry --- docs/src/content/docs/changelog/index.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/changelog/index.mdx b/docs/src/content/docs/changelog/index.mdx index 848353e7..f99ffb67 100644 --- a/docs/src/content/docs/changelog/index.mdx +++ b/docs/src/content/docs/changelog/index.mdx @@ -39,6 +39,7 @@ The following components have been extended to support onAction and anchor props | [XYSlider](/components/basic/xy-slider/#implemented-navigation-actions), [Scroll](/components/layout/scroll/#implemented-navigation-actions) | , | | [Tooltip](/components/feedback/tooltip/#implemented-navigation-actions) | , | | [TextBlock](/components/basic/text-block#props), [InlineTextBlock](/components/basic/inline-text-block#props) | (`onAction`, `anchor`) | +| [Keybind](/components/basic/keybinds#implemented-navigation-actions) | , | ### 📖 New Guides @@ -46,7 +47,9 @@ The following components have been extended to support onAction and anchor props - [GridTile](/components/layout/grid-tile) - Provide [a guide](/components/layout/grid-tile/#making-grid-tiles-navigable) on how to initialize grid tiles as navigable elements within a [Navigation](/components/utility/Navigation/) area. -## 1.2.0 :badge[Latest]{variant="note"} +- [Keybinds](/components/basic/keybinds) - Provide [an extend overview](/components/basic/keybinds#gamepad-support) on gamepad support. + +## 1.2.0 Added [Icon](/components/media/icon/) component. From d6101853be524fc7bce7e4133513bf265b3dee3b Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Fri, 6 Feb 2026 11:55:57 +0200 Subject: [PATCH 3/3] Resolve PR comments --- docs/src/content/docs/changelog/index.mdx | 2 +- .../content/docs/components/Basic/keybinds.mdx | 16 ++++++++++------ src/components/Basic/Keybinds/Keybinds.tsx | 2 +- src/components/Basic/Keybinds/util/glyphs.ts | 1 - .../Navigation/actionMethods/useActionMethods.ts | 2 +- tests/specs/keybinds.spec.js | 2 +- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/src/content/docs/changelog/index.mdx b/docs/src/content/docs/changelog/index.mdx index f99ffb67..15f0a796 100644 --- a/docs/src/content/docs/changelog/index.mdx +++ b/docs/src/content/docs/changelog/index.mdx @@ -47,7 +47,7 @@ The following components have been extended to support onAction and anchor props - [GridTile](/components/layout/grid-tile) - Provide [a guide](/components/layout/grid-tile/#making-grid-tiles-navigable) on how to initialize grid tiles as navigable elements within a [Navigation](/components/utility/Navigation/) area. -- [Keybinds](/components/basic/keybinds) - Provide [an extend overview](/components/basic/keybinds#gamepad-support) on gamepad support. +- [Keybinds](/components/basic/keybinds) - Provide [an extended overview](/components/basic/keybinds#gamepad-support) on gamepad support. ## 1.2.0 diff --git a/docs/src/content/docs/components/Basic/keybinds.mdx b/docs/src/content/docs/components/Basic/keybinds.mdx index b27ac273..7b1c7d30 100644 --- a/docs/src/content/docs/components/Basic/keybinds.mdx +++ b/docs/src/content/docs/components/Basic/keybinds.mdx @@ -161,7 +161,7 @@ import Keybind from '@components/Basic/Keybinds/Keybind'; For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). -## Gamepad Support +## Gamepad Support :badge[2.0.0]{variant=note} With the introduction of the [Navigation component](/components/utility/navigation/), the `Keybinds` component now supports gamepad input as well. @@ -172,8 +172,8 @@ Internally, the gamepad buttons are identified and stored by the [Gamepad API st while the axes are identified by custom aliases (e.g., `left.joystick.up`, `right.joystick.left`, etc.). See the [Glyphs object](#glyphs-object) table for the full list of supported gamepad button codes and axis aliases. -For easier readibility, you can use the same button codes and axis aliases used by the [Interaction Manager Gamepad Class](https://frontend-tools.coherent-labs.com/interaction-manager/features/gamepad/#options). -The will resolve to the correct button indices internally. +For easier readability, you can use the same button codes and axis aliases used by the [Interaction Manager Gamepad Class](https://frontend-tools.coherent-labs.com/interaction-manager/features/gamepad/#options). +These will resolve to the correct button indices internally. ### Usage @@ -208,20 +208,24 @@ Binding a value of `xbox.a` and `face-button-down` or even `0` will result in th ### Displayed glyphs -When displaying the bound keys, the component will render the corresponding glyphs for the gamepad buttons. The default glpyhs are based on the standard Xbox controller layout and are displayed by the +When displaying the bound keys, the component will render the corresponding glyphs for the gamepad buttons. The default glyphs are based on the standard Xbox controller layout and are displayed by the [Icon](/components/media/icon/) component. You can override the default glyphs by providing a custom `glyphOverrides` prop to the `Keybinds` component and providing your own components or elements for the desired buttons. -```tsx +```tsx {"You can pass overrides in the form of:": 5} {"- Component reference": 7-8} {"- Calling the component directly to be able to provide custom props such as styles,": 10-11} {" - HTML element such as an image": 12-13} import Keybinds from '@components/Basic/Keybinds/Keybinds'; import Keybind from '@components/Basic/Keybinds/Keybind'; const App = () => { + const GLYPHS: GlyphOverrides = { + '0': Icon.gamepad.ps5.cross, '1': Icon.gamepad.ps5.circle, + '2': , + '3': , } @@ -247,7 +251,7 @@ When overriding glyphs, make sure to use the exact same gamepad button codes and ### Styling the gamepad glyphs -If you wish to provide custom styling to the displayed glyphs such as changing the `wdith/height` of the displayed image, +If you wish to provide custom styling to the displayed glyphs such as changing the `width/height` of the displayed image, you can do so by overriding the glyph via the `glyphOverrides` prop and providing your own component or element with the desired styles. For example, to keep the default xbox glyphs but change their size, you can do the following: diff --git a/src/components/Basic/Keybinds/Keybinds.tsx b/src/components/Basic/Keybinds/Keybinds.tsx index cf2d0a94..b1564981 100644 --- a/src/components/Basic/Keybinds/Keybinds.tsx +++ b/src/components/Basic/Keybinds/Keybinds.tsx @@ -69,7 +69,7 @@ const Keybinds: ParentComponent = (props) => { if (!isNaN(num) || typeof input === 'number') { if (0 <= num && num <= 16) return String(input); else { - console.warn(`${input} is not a valid gamepad button. Please ensure the number is between 0 and 16`); + console.warn(`${input} is not a valid gamepad button. Please ensure the number is between 0 and 16 inclusive`); return null } } diff --git a/src/components/Basic/Keybinds/util/glyphs.ts b/src/components/Basic/Keybinds/util/glyphs.ts index 3033000c..6071b51e 100644 --- a/src/components/Basic/Keybinds/util/glyphs.ts +++ b/src/components/Basic/Keybinds/util/glyphs.ts @@ -1,5 +1,4 @@ import { Icon } from "@components/Media/Icon/Icon"; -import { GamepadMappings } from "coherent-gameface-interaction-manager"; import { Component, JSX } from "solid-js"; /** diff --git a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts index e1ef3f70..0de38d29 100644 --- a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts @@ -13,7 +13,7 @@ export default function createActionMethods( const actionSubscribers = new Map(); // For force paused actions const forcePausedActions = new Set(); - // For renenbering which already paused actions were affected in the pause input function + // For remembering which already paused actions were affected in the pause input function const inputCaptureSnapshot = new Set(); const registerAction = (actionName: ActionName) => { diff --git a/tests/specs/keybinds.spec.js b/tests/specs/keybinds.spec.js index 5a265dd7..b9fedb1f 100644 --- a/tests/specs/keybinds.spec.js +++ b/tests/specs/keybinds.spec.js @@ -296,7 +296,7 @@ describe('Keybinds', function () { assert.equal(src.includes(`/b`), true, 'Correct icon is displayed'); }) - it('Should prevent exection of any actions while listening for input', async () => { + it('Should prevent execution of any actions while listening for input', async () => { const assertionEl = await gf.get(`.${selectors.actionTest}`) const gamepad = gf.getGamepad('gamepad')