From ffc11832eda65c3da1077439488f4071c811a681 Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Fri, 21 Nov 2025 13:56:38 +0200 Subject: [PATCH 01/29] Add Navigation component and expose api --- .../Navigation/Navigation/Navigation.tsx | 87 +++++++++++++++ .../Navigation/Navigation/NavigationArea.tsx | 41 +++++++ .../actionMethods/actionMethods.types.ts | 58 ++++++++++ .../actionMethods/useActionMethods.ts | 101 ++++++++++++++++++ .../areaMethods/areaMethods.types.ts | 56 ++++++++++ .../Navigation/areaMethods/useAreaMethods.ts | 77 +++++++++++++ .../Navigation/Navigation/defaults.ts | 44 ++++++++ src/components/Navigation/Navigation/types.ts | 23 ++++ 8 files changed, 487 insertions(+) create mode 100644 src/components/Navigation/Navigation/Navigation.tsx create mode 100644 src/components/Navigation/Navigation/NavigationArea.tsx create mode 100644 src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts create mode 100644 src/components/Navigation/Navigation/actionMethods/useActionMethods.ts create mode 100644 src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts create mode 100644 src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts create mode 100644 src/components/Navigation/Navigation/defaults.ts create mode 100644 src/components/Navigation/Navigation/types.ts diff --git a/src/components/Navigation/Navigation/Navigation.tsx b/src/components/Navigation/Navigation/Navigation.tsx new file mode 100644 index 0000000..5bdc4e6 --- /dev/null +++ b/src/components/Navigation/Navigation/Navigation.tsx @@ -0,0 +1,87 @@ +import { createContext, onCleanup, onMount, ParentComponent, useContext } from "solid-js" +// @ts-ignore +import { gamepad } from 'coherent-gameface-interaction-manager'; +import NavigationArea from "./NavigationArea"; +import eventBus from "@components/tools/EventBus"; +import { createStore } from "solid-js/store"; +import { ActionMap, NavigationConfigType } from "./types"; +import { DEFAULT_ACTIONS } from "./defaults"; +import createAreaMethods from "./areaMethods/useAreaMethods"; +import createActionMethods from "./actionMethods/useActionMethods"; +import { AreaMethods } from "./areaMethods/areaMethods.types"; +import { ActionMethods } from "./actionMethods/actionMethods.types"; + +type ExcludedAPIMethods = 'registerAction' | 'unregisterAction' +interface NavigationContextType extends AreaMethods, Omit {} +export interface NavigationRef extends NavigationContextType {} + +export const NavigationContext = createContext(); +export const useNavigation = () => { + const context = useContext(NavigationContext); + if (!context) throw new Error('useNavigation must be used within Navigation'); + return context; +} + +interface NavigationProps { + gamepad?: boolean, + keyboard?: boolean, + actions?: ActionMap, + scope?: string, + pollingInterval?: number + ref?: NavigationContextType; + overlap?: number +} + +const Navigation: ParentComponent = (props) => { + const [config, setConfig] = createStore({ + gamepad: props.gamepad ?? true, + keyboard: props.keyboard ?? true, + actions: {...DEFAULT_ACTIONS, ...props.actions}, + scope: props.scope ?? "", + }) + const areas = new Set(); + const { addAction, executeAction, registerAction, removeAction, unregisterAction, updateAction, getScope } = createActionMethods(config, setConfig) + const areaMethods = createAreaMethods(areas, setConfig); + + const navigationAPI = { + addAction, + removeAction, + executeAction, + updateAction, + getScope, + ...areaMethods, + } + + const initActions = () => { + for (const action in config.actions) { + registerAction(action) + } + } + + const deInitActions = () => { + for (const action in config.actions) { + unregisterAction(action) + } + } + + onMount(() => { + if (config.gamepad) { + gamepad.enabled = true; + gamepad.pollingInterval = props.pollingInterval ?? 200; + } + initActions() + eventBus.on('select', (scope: string) => console.log(scope)) + if (!props.ref) return; + (props.ref as unknown as (ref: NavigationContextType) => void)(navigationAPI); + }) + + onCleanup(() => deInitActions()) + + return ( + + {props.children} + + ) +} + +export default Object.assign(Navigation, { Area: NavigationArea }); \ No newline at end of file diff --git a/src/components/Navigation/Navigation/NavigationArea.tsx b/src/components/Navigation/Navigation/NavigationArea.tsx new file mode 100644 index 0000000..2da5f9a --- /dev/null +++ b/src/components/Navigation/Navigation/NavigationArea.tsx @@ -0,0 +1,41 @@ +import { children, createEffect, on, onCleanup, onMount, ParentComponent, useContext } from "solid-js" +import { NavigationContext } from "./Navigation"; + +interface NavigationAreaProps { + name: string, + selector?: string, + focused?: boolean, +} + +const NavigationArea: ParentComponent = (props) => { + const context = useContext(NavigationContext); + const cachedChildren = children(() => props.children); + const navigatableElements = props.selector ? [`.${props.selector}`] : cachedChildren(); + + const refresh = () => { + context!.unregisterArea(props.name); + context!.registerArea(props.name, navigatableElements as HTMLElement[], false); + } + + // Refresh whenever children change + createEffect(on(cachedChildren, refresh, { defer: true })) + + onMount(() => { + if (!context) { + console.warn('No context bro'); + return null + } + + context!.registerArea(props.name, navigatableElements as HTMLElement[], props.focused ?? false); + }) + + onCleanup(() => { + context!.unregisterArea(props.name); + }) + + return ( + <>{cachedChildren()} + ) +} + +export default NavigationArea; \ No newline at end of file diff --git a/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts b/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts new file mode 100644 index 0000000..d747651 --- /dev/null +++ b/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts @@ -0,0 +1,58 @@ +import { ActionName, ActionCfg } from '../types'; + +/** + * Interface defining all action-related navigation methods + */ +export interface ActionMethods { + /** + * Adds a new Navigation action and registers it with the interaction manager + * @param name - The name of the action + * @param config - The action configuration object with the following properties: + * - `key?`: Keyboard binding `{binds: string[], type?: ('press' | 'hold' | 'lift')[]}` + * - `button?`: Gamepad binding `{binds: string[], type?: ('press' | 'hold')}` + * - `callback?`: Function to execute when action is triggered + * - `global?`: Whether to emit action globally via event bus + */ + addAction: (name: ActionName, config: ActionCfg) => void; + + /** + * Removes a registered Navigation action and unregisters it from the interaction manager + * @param name - The name of the action to remove + */ + removeAction: (name: ActionName) => void; + + /** + * Updates an existing action's configuration. Default actions can be updated as well. + * @param name - The name of the action to update + * @param config - The new action configuration object with the following properties: + * - `key?`: Keyboard binding `{binds: string[], type?: ('press' | 'hold' | 'lift')[]}` + * - `button?`: Gamepad binding `{binds: string[], type?: ('press' | 'hold')}` + * - `callback?`: Function to execute when action is triggered + * - `global?`: Whether to emit action globally via event bus + */ + updateAction: (name: ActionName, config: ActionCfg) => void; + + /** + * Executes a registered action by name + * @param name - The name of the action to execute + */ + executeAction: (name: ActionName) => void; + + /** + * Registers an action with the interaction manager (keyboard/gamepad bindings) + * @param actionName - The name of the action to register + */ + registerAction: (actionName: ActionName) => void; + + /** + * Unregisters an action and removes keyboard/gamepad bindings + * @param actionName - The name of the action to unregister + */ + unregisterAction: (actionName: ActionName) => void; + + /** + * Gets the current navigation scope + * @returns The current scope identifier (typically the name of the active navigation area) + */ + getScope: () => string; +} diff --git a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts new file mode 100644 index 0000000..a732dc0 --- /dev/null +++ b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts @@ -0,0 +1,101 @@ +// @ts-ignore +import { actions, keyboard, gamepad } from 'coherent-gameface-interaction-manager'; +import { SetStoreFunction } from 'solid-js/store'; +import { ActionName, ActionCfg, DefaultActions, NavigationConfigType } from '../types'; +import { DEFAULT_ACTION_NAMES } from '../defaults'; +import eventBus from '@components/tools/EventBus'; +import { ActionMethods } from './actionMethods.types'; + +export default function createActionMethods( + config: NavigationConfigType, + setConfig: SetStoreFunction +): ActionMethods { + const registerAction = (actionName: ActionName) => { + const {key, button, callback, global} = config.actions[actionName]!; + + const shouldEmitGlobally = DEFAULT_ACTION_NAMES.has(actionName as DefaultActions) || global; + + actions.register(actionName, () => { + callback && callback(config.scope); + if (shouldEmitGlobally) eventBus.emit(actionName, config.scope); + }) + + if (config.keyboard && key) { + keyboard.on({ + keys: key.binds, + callback: actionName, + type: key.type || ['press'], + }) + } + + if (config.gamepad && button) { + gamepad.on({ + actions: button.binds, + callback: actionName, + type: button.type + }); + } + } + + const unregisterAction = (actionName: ActionName) => { + const {key, button} = config.actions[actionName]!; + + if (config.keyboard && key) keyboard.off(key.binds, actionName) + if (config.gamepad && button) gamepad.off(button.binds, actionName) + actions.remove(actionName) + } + + const addAction = (name: ActionName, data: ActionCfg) => { + if (config.actions[name]) { + return console.warn(`Action ${name} is already registered! If you wish to update it's data use updateAction() instead.`) + } + setConfig('actions', name, data); + registerAction(name); + } + + const removeAction = (name: ActionName) => { + if (!config.actions[name]) { + return console.warn('Trying to remove a non existing action!') + } + + if (DEFAULT_ACTION_NAMES.has(name as DefaultActions)) { + return console.warn('Can\'t remove a default action!') + } + + unregisterAction(name); + setConfig('actions', (prev) => { + const { [name]: _, ...rest } = prev; + return rest; + }) + } + + const updateAction = (name: ActionName, data: ActionCfg) => { + if (!config.actions[name]) { + return console.warn('Trying to update a non existing action!') + } + + unregisterAction(name) + setConfig('actions', name, data); + registerAction(name); + } + + const executeAction = (name: ActionName) => { + if (!config.actions[name]) { + return console.warn('Trying to execute a non existing action!') + } + + actions.execute(name); + } + + const getScope = () => config.scope; + + return { + addAction, + removeAction, + updateAction, + executeAction, + registerAction, + unregisterAction, + getScope + }; +} diff --git a/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts b/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts new file mode 100644 index 0000000..15f624f --- /dev/null +++ b/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts @@ -0,0 +1,56 @@ +/** + * Interface defining all area-related navigation methods + */ +export interface AreaMethods { + /** + * Registers a navigation area with focusable elements + * @param area - The name of the navigation area to register + * @param elements - Array of CSS selectors or HTML elements to include in the navigation area + * @param focused - Whether to automatically focus the first element in this area after registration + */ + registerArea: (area: string, elements: string[] | HTMLElement[], focused?: boolean) => void; + + /** + * Unregisters a navigation area and removes it from spatial navigation + * @param area - The name of the navigation area to unregister + */ + unregisterArea: (area: string) => void; + + /** + * Focuses the first focusable element in the specified area and updates the navigation scope + * @param area - The name of the navigation area to focus + */ + focusFirst: (area: string) => void; + + /** + * Focuses the last focusable element in the specified area and updates the navigation scope + * @param area - The name of the navigation area to focus + */ + focusLast: (area: string) => void; + + /** + * Switches the active navigation to the specified area by focusing the first element in it and updating the navigation scope + * @param area - The name of the navigation area to switch to + */ + switchArea: (area: string) => void; + + /** + * Clears the current focus from all navigation areas + */ + clearFocus: () => void; + + /** + * Changes the navigation keys for spatial navigation + * @param keys - Object containing direction keys (up, down, left, right) + * @param clearCurrent - Whether to clear current active keys before setting new ones + */ + changeNavigationKeys: ( + keys: { up?: string; down?: string; left?: string; right?: string }, + clearCurrent?: boolean + ) => void; + + /** + * Resets navigation keys to their default values + */ + resetNavigationKeys: () => void; +} diff --git a/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts b/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts new file mode 100644 index 0000000..5b46457 --- /dev/null +++ b/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts @@ -0,0 +1,77 @@ +import { waitForFrames } from '@components/utils/waitForFrames'; +// @ts-ignore +import { spatialNavigation } from 'coherent-gameface-interaction-manager'; +import { SetStoreFunction } from 'solid-js/store'; +import { NavigationConfigType } from '../types'; +import { AreaMethods } from './areaMethods.types'; + +export default function createAreaMethods( + areas: Set, + setConfig: SetStoreFunction +): AreaMethods { + const registerArea = (area: string, elements: string[] | HTMLElement[], focused?: boolean) => { + waitForFrames(() => { + const enabled = spatialNavigation.enabled; + spatialNavigation[enabled ? 'add' : 'init']([ + { area: area, elements: elements }, + ]); + areas.add(area); + focused && focusFirst(area); + }) + } + + const unregisterArea = (area: string) => { + spatialNavigation.remove(area) + areas.delete(area); + if (areas.size === 0) spatialNavigation.deinit(); + } + + const focusFirst = (area: string) => { + if (!areas.has(area)) { + console.warn(`Area "${area}" not registered. Available areas:`, Array.from(areas)); + return; + } + + spatialNavigation.focusFirst(area); + setConfig('scope', area); + }; + + const focusLast = (area: string) => { + if (!areas.has(area)) { + console.warn(`Area "${area}" not registered. Available areas:`, Array.from(areas)); + return; + } + + spatialNavigation.focusLast(area); + setConfig('scope', area); + }; + + const switchArea = (area: string) => { + if (!areas.has(area)) { + console.warn(`Area "${area}" not registered. Available areas:`, Array.from(areas)); + return; + } + + spatialNavigation.switchArea(area); + setConfig('scope', area); + }; + + const clearFocus = () => spatialNavigation.clearFocus(); + + const changeNavigationKeys = (keys: { up?: string, down?: string, left?: string, right?: string}, clearCurrent = false) => { + spatialNavigation.changeKeys(keys, { clearCurrentActiveKeys: clearCurrent }); + } + + const resetNavigationKeys = () => spatialNavigation.resetKeys(); + + return { + registerArea, + unregisterArea, + focusFirst, + focusLast, + switchArea, + clearFocus, + changeNavigationKeys, + resetNavigationKeys + }; +} diff --git a/src/components/Navigation/Navigation/defaults.ts b/src/components/Navigation/Navigation/defaults.ts new file mode 100644 index 0000000..fc37663 --- /dev/null +++ b/src/components/Navigation/Navigation/defaults.ts @@ -0,0 +1,44 @@ +import { ActionMap, DefaultActions } from "./types"; + +export const DEFAULT_ACTIONS: ActionMap = { + 'move-left': { + key: {binds: ['ARROW_LEFT'], type: ['press']}, + button: {binds: ['pad-left'], type: 'press'}, + callback: undefined as any, + global: true + }, + 'move-right': { + key: {binds: ['ARROW_RIGHT'], type: ['press']}, + button: {binds: ['pad-right'], type: 'press'}, + callback: undefined as any, + global: true + }, + 'move-up': { + key: {binds: ['ARROW_UP'], type: ['press']}, + button: {binds: ['pad-up'], type: 'press'}, + callback: undefined as any, + global: true + }, + 'move-down': { + key: {binds: ['ARROW_DOWN'], type: ['press']}, + button: {binds: ['pad-down'], type: 'press'}, + callback: undefined as any, + global: true + }, + 'select': { + key: {binds: ['Enter'], type: ['press']}, + button: {binds: ['face-button-down'], type: 'press'}, + callback: undefined as any, + global: true + }, + 'back': { + key: {binds: ['ESC'], type: ['press']}, + button: {binds: ['face-button-right'], type: 'press'}, + callback: undefined as any, + global: true + }, +}; + +export const DEFAULT_ACTION_NAMES = new Set( + Object.keys(DEFAULT_ACTIONS) as DefaultActions[] +); \ No newline at end of file diff --git a/src/components/Navigation/Navigation/types.ts b/src/components/Navigation/Navigation/types.ts new file mode 100644 index 0000000..dff711d --- /dev/null +++ b/src/components/Navigation/Navigation/types.ts @@ -0,0 +1,23 @@ +type ActionType = 'press' | 'hold' | 'lift'; + +export type ActionCfg = { + key?: {binds: string[], type?: ActionType[]}; // replace with key map + button?: {binds: string[], type?: Exclude} // replace with button map + callback: (scope?: string, ...args: any[]) => void + global?: boolean +}; + +export type DefaultActions = 'move-left' | 'move-right' | 'move-up' | 'move-down' | 'select' | 'back'; +export type ActionName = DefaultActions | (string & {}); +export type ActionMap = { + [K in DefaultActions]?: ActionCfg; +} & { + [key: string]: ActionCfg; +}; + +export interface NavigationConfigType { + gamepad: boolean, + keyboard: boolean, + actions: ActionMap, + scope: string +} \ No newline at end of file From 1f38cdd893760006cc70ef764e3cff9c193edf4b Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Fri, 21 Nov 2025 16:22:38 +0200 Subject: [PATCH 02/29] Add type suggestions for keys and buttons --- .../Navigation/Navigation/defaults.ts | 2 +- .../keybindings/keybindings.types.ts | 181 ++++++++++++++++++ src/components/Navigation/Navigation/types.ts | 6 +- 3 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/components/Navigation/Navigation/keybindings/keybindings.types.ts diff --git a/src/components/Navigation/Navigation/defaults.ts b/src/components/Navigation/Navigation/defaults.ts index fc37663..fb86bd4 100644 --- a/src/components/Navigation/Navigation/defaults.ts +++ b/src/components/Navigation/Navigation/defaults.ts @@ -26,7 +26,7 @@ export const DEFAULT_ACTIONS: ActionMap = { global: true }, 'select': { - key: {binds: ['Enter'], type: ['press']}, + key: {binds: ['ENTER'], type: ['press']}, button: {binds: ['face-button-down'], type: 'press'}, callback: undefined as any, global: true diff --git a/src/components/Navigation/Navigation/keybindings/keybindings.types.ts b/src/components/Navigation/Navigation/keybindings/keybindings.types.ts new file mode 100644 index 0000000..68fb17f --- /dev/null +++ b/src/components/Navigation/Navigation/keybindings/keybindings.types.ts @@ -0,0 +1,181 @@ +// REPLACE WITH BINDINGS FROM IM WHEN ITS MIGRATED TO TS + +/** + * All valid keyboard key bindings supported by Gameface + * Based on coherent-gameface-interaction-manager keyboard-key-codes + */ +export type KeyBinding = + | 'ALT' + | 'ARROW_DOWN' + | 'ARROW_LEFT' + | 'ARROW_RIGHT' + | 'ARROW_UP' + | 'BACKSPACE' + | 'CAPS_LOCK' + | 'CTRL' + | 'DELETE' + | 'END' + | 'ENTER' + | 'ESC' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'HOME' + | 'INSERT' + | 'NUM_LOCK' + | 'NUMPAD_ENTER' + | 'NUMPAD_DASH' + | 'NUMPAD_STAR' + | 'NUMPAD_DOT' + | 'NUMPAD_FORWARD_SLASH' + | 'NUMPAD_PLUS' + | 'NUMPAD_0' + | 'NUMPAD_1' + | 'NUMPAD_2' + | 'NUMPAD_3' + | 'NUMPAD_4' + | 'NUMPAD_5' + | 'NUMPAD_6' + | 'NUMPAD_7' + | 'NUMPAD_8' + | 'NUMPAD_9' + | 'PAGE_DOWN' + | 'PAGE_UP' + | 'PAUSE' + | 'PRINT_SCRN' + | 'SCROLL_LOCK' + | 'SHIFT' + | 'SPACEBAR' + | 'TAB' + | 'A' + | 'B' + | 'C' + | 'D' + | 'E' + | 'F' + | 'G' + | 'H' + | 'I' + | 'J' + | 'K' + | 'L' + | 'M' + | 'N' + | 'O' + | 'P' + | 'Q' + | 'R' + | 'S' + | 'T' + | 'U' + | 'V' + | 'W' + | 'X' + | 'Y' + | 'Z' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | '0' + | 'QUOTE' + | 'DASH' + | 'COMMA' + | 'DOT' + | 'FORWARD_SLASH' + | 'SEMI_COLON' + | 'SQUARE_BRACKET_LEFT' + | 'SQUARE_BRACKET_RIGHT' + | 'BACKWARD_SLASH' + | 'BACKTICK' + | 'EQUAL' + | 'SYSTEM'; + +/** + * All valid gamepad button bindings supported by Gameface + * Includes uppercase names and lowercase aliases (xbox.*, playstation.*, etc.) + * Based on coherent-gameface-interaction-manager gamepad-key-codes + */ +export type GamepadButton = + | 'FACE_BUTTON_DOWN' + | 'FACE_BUTTON_RIGHT' + | 'FACE_BUTTON_LEFT' + | 'FACE_BUTTON_TOP' + | 'LEFT_SHOULDER' + | 'RIGHT_SHOULDER' + | 'LEFT_SHOULDER_BOTTOM' + | 'RIGHT_SHOULDER_BOTTOM' + | 'SELECT' + | 'START' + | 'LEFT_ANALOGUE_STICK' + | 'RIGHT_ANALOGUE_STICK' + | 'PAD_UP' + | 'PAD_DOWN' + | 'PAD_LEFT' + | 'PAD_RIGHT' + | 'CENTER_BUTTON' + | 'face-button-down' + | 'face-button-left' + | 'face-button-right' + | 'face-button-top' + | 'left-sholder' + | 'right-sholder' + | 'left-sholder-bottom' + | 'right-sholder-bottom' + | 'select' + | 'start' + | 'left-analogue-stick' + | 'right-analogue-stick' + | 'pad-up' + | 'pad-down' + | 'pad-left' + | 'pad-right' + | 'center-button' + | 'playstation.x' + | 'playstation.square' + | 'playstation.circle' + | 'playstation.triangle' + | 'playstation.l1' + | 'playstation.r1' + | 'playstation.l2' + | 'playstation.r2' + | 'playstation.share' + | 'playstation.options' + | 'playstation.l3' + | 'playstation.r3' + | 'playstation.d-pad-up' + | 'playstation.d-pad-down' + | 'playstation.d-pad-left' + | 'playstation.d-pad-right' + | 'playstation.center' + | 'xbox.a' + | 'xbox.x' + | 'xbox.b' + | 'xbox.y' + | 'xbox.lb' + | 'xbox.rb' + | 'xbox.lt' + | 'xbox.rt' + | 'xbox.view' + | 'xbox.menu' + | 'xbox.left-thumbstick' + | 'xbox.right-thumbstick' + | 'xbox.d-pad-up' + | 'xbox.d-pad-down' + | 'xbox.d-pad-left' + | 'xbox.d-pad-right' + | 'xbox.center'; diff --git a/src/components/Navigation/Navigation/types.ts b/src/components/Navigation/Navigation/types.ts index dff711d..376e383 100644 --- a/src/components/Navigation/Navigation/types.ts +++ b/src/components/Navigation/Navigation/types.ts @@ -1,8 +1,10 @@ +import { KeyBinding, GamepadButton } from './keybindings/keybindings.types'; + type ActionType = 'press' | 'hold' | 'lift'; export type ActionCfg = { - key?: {binds: string[], type?: ActionType[]}; // replace with key map - button?: {binds: string[], type?: Exclude} // replace with button map + key?: {binds: KeyBinding[], type?: ActionType[]}; + button?: {binds: GamepadButton[], type?: Exclude} callback: (scope?: string, ...args: any[]) => void global?: boolean }; From 0464b561ed77f7387b26e80245d51da470aed692 Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Fri, 21 Nov 2025 20:14:49 +0200 Subject: [PATCH 03/29] Add directive support for components to implement --- .../BaseComponent/BaseComponent.tsx | 57 ++++++++++++++++++- .../Navigation/Navigation/Navigation.tsx | 4 +- .../actionMethods/actionMethods.types.ts | 15 ++++- .../actionMethods/useActionMethods.ts | 18 +++--- src/components/types/ComponentProps.d.ts | 9 +++ 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/components/BaseComponent/BaseComponent.tsx b/src/components/BaseComponent/BaseComponent.tsx index 871e36e..9472374 100644 --- a/src/components/BaseComponent/BaseComponent.tsx +++ b/src/components/BaseComponent/BaseComponent.tsx @@ -1,5 +1,8 @@ -import { ComponentProps } from "@components/types/ComponentProps"; -import { createEffect } from "solid-js"; +import { useNavigation } from "@components/Navigation/Navigation/Navigation"; +import eventBus from "@components/tools/EventBus"; +import { ComponentProps, NavigationActionsConfig } from "@components/types/ComponentProps"; +import { Accessor, createEffect } from "solid-js"; +import { waitForFrames } from "@components/utils/waitForFrames"; const baseEventsSet = new Set([ "abort", @@ -83,6 +86,54 @@ function forwardEvents(el: HTMLElement, getData: () => Record) { } } +function navigationActions(el: HTMLElement, accessor: Accessor) { + const nav = useNavigation(); + if (!nav) return; + + const config = accessor(); + const { anchor, ...actionHandlers } = config; + + el.setAttribute('tabindex', '0'); + + let anchorElement: HTMLElement | null = null; + if (anchor) { + if (typeof anchor === 'string') { + waitForFrames(() => anchorElement = document.querySelector(anchor)); + } else { + anchorElement = anchor; + } + } + + const isFocused = () => { + const active = document.activeElement; + return active === el || + (anchorElement && active === anchorElement) || + el.contains(active); + }; + + const listeners: Array<[string, (args: any) => void]> = []; + for (const [name, func] of Object.entries(actionHandlers)) { + const action = nav.getAction(name); + if (!action) { + console.warn(`Action "${name}" is not registered in Navigation`); + continue; + } + + const handler = (args: any) => { + if (isFocused()) (func as Function)(args); + }; + + eventBus.on(name, handler); + listeners.push([name, handler]); + } + + return () => { + for (const [name, handler] of listeners) { + eventBus.off(name, handler); + } + }; +} + export function useBaseComponent(props: ComponentProps) { const className = () => { const classes = (typeof props.componentClasses === "function" ? props.componentClasses() : props.componentClasses || '') + " " + (props.class || ''); @@ -94,7 +145,7 @@ export function useBaseComponent(props: ComponentProps) { ...props.style }); - return { className, inlineStyles, forwardAttrs, forwardEvents }; + return { className, inlineStyles, forwardAttrs, forwardEvents, navigationActions }; } export default useBaseComponent; \ No newline at end of file diff --git a/src/components/Navigation/Navigation/Navigation.tsx b/src/components/Navigation/Navigation/Navigation.tsx index 5bdc4e6..d86c43a 100644 --- a/src/components/Navigation/Navigation/Navigation.tsx +++ b/src/components/Navigation/Navigation/Navigation.tsx @@ -40,7 +40,7 @@ const Navigation: ParentComponent = (props) => { scope: props.scope ?? "", }) const areas = new Set(); - const { addAction, executeAction, registerAction, removeAction, unregisterAction, updateAction, getScope } = createActionMethods(config, setConfig) + const { addAction, executeAction, registerAction, removeAction, unregisterAction, updateAction, getScope, getAction, getActions } = createActionMethods(config, setConfig) const areaMethods = createAreaMethods(areas, setConfig); const navigationAPI = { @@ -49,6 +49,8 @@ const Navigation: ParentComponent = (props) => { executeAction, updateAction, getScope, + getAction, + getActions, ...areaMethods, } diff --git a/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts b/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts index d747651..930da9e 100644 --- a/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts +++ b/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts @@ -1,4 +1,4 @@ -import { ActionName, ActionCfg } from '../types'; +import { ActionName, ActionCfg, ActionMap } from '../types'; /** * Interface defining all action-related navigation methods @@ -55,4 +55,17 @@ export interface ActionMethods { * @returns The current scope identifier (typically the name of the active navigation area) */ getScope: () => string; + + /** + * Gets a specific action configuration by name + * @param name - The action name to retrieve + * @returns The action configuration if it exists, undefined otherwise + */ + getAction: (name: ActionName) => ActionCfg | undefined; + + /** + * Gets all currently registered actions + * @returns Record of all action names and their configurations + */ + getActions: () => ActionMap; } diff --git a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts index a732dc0..ec99215 100644 --- a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts @@ -11,7 +11,7 @@ export default function createActionMethods( setConfig: SetStoreFunction ): ActionMethods { const registerAction = (actionName: ActionName) => { - const {key, button, callback, global} = config.actions[actionName]!; + const {key, button, callback, global} = getAction(actionName)!; const shouldEmitGlobally = DEFAULT_ACTION_NAMES.has(actionName as DefaultActions) || global; @@ -38,7 +38,7 @@ export default function createActionMethods( } const unregisterAction = (actionName: ActionName) => { - const {key, button} = config.actions[actionName]!; + const {key, button} = getAction(actionName)!; if (config.keyboard && key) keyboard.off(key.binds, actionName) if (config.gamepad && button) gamepad.off(button.binds, actionName) @@ -46,7 +46,7 @@ export default function createActionMethods( } const addAction = (name: ActionName, data: ActionCfg) => { - if (config.actions[name]) { + if (getAction(name)) { return console.warn(`Action ${name} is already registered! If you wish to update it's data use updateAction() instead.`) } setConfig('actions', name, data); @@ -54,7 +54,7 @@ export default function createActionMethods( } const removeAction = (name: ActionName) => { - if (!config.actions[name]) { + if (!getAction(name)) { return console.warn('Trying to remove a non existing action!') } @@ -70,7 +70,7 @@ export default function createActionMethods( } const updateAction = (name: ActionName, data: ActionCfg) => { - if (!config.actions[name]) { + if (!getAction(name)) { return console.warn('Trying to update a non existing action!') } @@ -80,7 +80,7 @@ export default function createActionMethods( } const executeAction = (name: ActionName) => { - if (!config.actions[name]) { + if (!getAction(name)) { return console.warn('Trying to execute a non existing action!') } @@ -88,6 +88,8 @@ export default function createActionMethods( } const getScope = () => config.scope; + const getAction = (name: ActionName) => config.actions[name]; + const getActions = () => config.actions; return { addAction, @@ -96,6 +98,8 @@ export default function createActionMethods( executeAction, registerAction, unregisterAction, - getScope + getScope, + getAction, + getActions, }; } diff --git a/src/components/types/ComponentProps.d.ts b/src/components/types/ComponentProps.d.ts index d3e9cb5..2412b3b 100644 --- a/src/components/types/ComponentProps.d.ts +++ b/src/components/types/ComponentProps.d.ts @@ -1,5 +1,6 @@ import { JSX, ParentProps } from "solid-js"; import Events from "./BaseComponent"; +import { ActionName } from "@components/Navigation/Navigation/types"; type ExcludedEvents = | "abort" @@ -37,6 +38,13 @@ export interface TokenComponentProps { parentChildren: JSX.Element, } +type NavigationActionHandler = (scope?: string) => void; +export type NavigationActionsConfig = { + anchor?: HTMLElement | string; +} & { + [K in ActionName]?: NavigationActionHandler | HTMLElement | string | undefined; +} + declare module "solid-js" { namespace JSX { interface IntrinsicElements { @@ -46,6 +54,7 @@ declare module "solid-js" { interface Directives { forwardEvents: ComponentProps; forwardAttrs: ComponentProps; + navigationActions: NavigationActionsConfig; } } } \ No newline at end of file From c5ff49b495b3bb4b7982d81a4854dbe8359a5a5b Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Mon, 24 Nov 2025 12:01:55 +0200 Subject: [PATCH 04/29] Add a way to pause and resume actions --- .../Navigation/Navigation/Navigation.tsx | 18 +++++++++++++- .../actionMethods/actionMethods.types.ts | 19 +++++++++++++++ .../actionMethods/useActionMethods.ts | 24 +++++++++++++++++++ src/components/Navigation/Navigation/types.ts | 3 ++- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/components/Navigation/Navigation/Navigation.tsx b/src/components/Navigation/Navigation/Navigation.tsx index d86c43a..348d476 100644 --- a/src/components/Navigation/Navigation/Navigation.tsx +++ b/src/components/Navigation/Navigation/Navigation.tsx @@ -40,7 +40,20 @@ const Navigation: ParentComponent = (props) => { scope: props.scope ?? "", }) const areas = new Set(); - const { addAction, executeAction, registerAction, removeAction, unregisterAction, updateAction, getScope, getAction, getActions } = createActionMethods(config, setConfig) + const { + addAction, + executeAction, + registerAction, + removeAction, + unregisterAction, + updateAction, + getScope, + getAction, + getActions, + isPaused, + pauseAction, + resumeAction + } = createActionMethods(config, setConfig) const areaMethods = createAreaMethods(areas, setConfig); const navigationAPI = { @@ -51,6 +64,9 @@ const Navigation: ParentComponent = (props) => { getScope, getAction, getActions, + isPaused, + pauseAction, + resumeAction, ...areaMethods, } diff --git a/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts b/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts index 930da9e..08579ab 100644 --- a/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts +++ b/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts @@ -68,4 +68,23 @@ export interface ActionMethods { * @returns Record of all action names and their configurations */ getActions: () => ActionMap; + + /** + * Pauses an action, preventing its callback from executing + * @param action - The name of the action to pause + */ + pauseAction: (action: ActionName) => void; + + /** + * Resumes a paused action, allowing its callback to execute again + * @param action - The name of the action to resume + */ + resumeAction: (action: ActionName) => void; + + /** + * Checks if an action is currently paused + * @param action - The name of the action to check + * @returns True if the action is paused, false otherwise + */ + isPaused: (action: ActionName) => boolean; } diff --git a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts index ec99215..c83fded 100644 --- a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts @@ -16,6 +16,7 @@ export default function createActionMethods( const shouldEmitGlobally = DEFAULT_ACTION_NAMES.has(actionName as DefaultActions) || global; actions.register(actionName, () => { + if (isPaused(actionName)) return; callback && callback(config.scope); if (shouldEmitGlobally) eventBus.emit(actionName, config.scope); }) @@ -91,6 +92,26 @@ export default function createActionMethods( const getAction = (name: ActionName) => config.actions[name]; const getActions = () => config.actions; + const pauseAction = (name: ActionName) => { + if (!getAction(name)) { + return console.warn('Action not found'); + } + + setConfig('actions', name, 'paused', true); + }; + + const resumeAction = (name: ActionName) => { + const action = getAction(name); + if (!action) return console.warn('Action not found'); + if (!action.paused) return; + + setConfig('actions', name, 'paused', false); + }; + + const isPaused = (name: ActionName) => { + return getAction(name)?.paused ?? false; + }; + return { addAction, removeAction, @@ -101,5 +122,8 @@ export default function createActionMethods( getScope, getAction, getActions, + pauseAction, + resumeAction, + isPaused, }; } diff --git a/src/components/Navigation/Navigation/types.ts b/src/components/Navigation/Navigation/types.ts index 376e383..d44b992 100644 --- a/src/components/Navigation/Navigation/types.ts +++ b/src/components/Navigation/Navigation/types.ts @@ -6,7 +6,8 @@ export type ActionCfg = { key?: {binds: KeyBinding[], type?: ActionType[]}; button?: {binds: GamepadButton[], type?: Exclude} callback: (scope?: string, ...args: any[]) => void - global?: boolean + global?: boolean, + paused?: boolean, }; export type DefaultActions = 'move-left' | 'move-right' | 'move-up' | 'move-down' | 'select' | 'back'; From 1e1a4e9e1ac66a2947a0635efd6c4daf7e8e165c Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Tue, 25 Nov 2025 13:11:36 +0200 Subject: [PATCH 05/29] Add spatial nav pause/resume --- .../Navigation/Navigation/Navigation.tsx | 54 ++++++++----------- .../Navigation/Navigation/NavigationArea.tsx | 26 ++++++--- .../actionMethods/useActionMethods.ts | 4 +- .../areaMethods/areaMethods.types.ts | 16 ++++++ .../Navigation/areaMethods/useAreaMethods.ts | 49 +++++++++++++++-- src/components/Navigation/Navigation/types.ts | 4 +- 6 files changed, 106 insertions(+), 47 deletions(-) diff --git a/src/components/Navigation/Navigation/Navigation.tsx b/src/components/Navigation/Navigation/Navigation.tsx index 348d476..b743b93 100644 --- a/src/components/Navigation/Navigation/Navigation.tsx +++ b/src/components/Navigation/Navigation/Navigation.tsx @@ -1,4 +1,4 @@ -import { createContext, onCleanup, onMount, ParentComponent, useContext } from "solid-js" +import { Accessor, createContext, onCleanup, onMount, ParentComponent, useContext } from "solid-js" // @ts-ignore import { gamepad } from 'coherent-gameface-interaction-manager'; import NavigationArea from "./NavigationArea"; @@ -11,8 +11,11 @@ import createActionMethods from "./actionMethods/useActionMethods"; import { AreaMethods } from "./areaMethods/areaMethods.types"; import { ActionMethods } from "./actionMethods/actionMethods.types"; -type ExcludedAPIMethods = 'registerAction' | 'unregisterAction' -interface NavigationContextType extends AreaMethods, Omit {} +type ExcludedActionMethods = 'registerAction' | 'unregisterAction' +type ExcludedAreaMethods = 'isEnabled' +interface NavigationContextType extends Omit, Omit { + _navigationEnabled: Accessor +} export interface NavigationRef extends NavigationContextType {} export const NavigationContext = createContext(); @@ -27,9 +30,9 @@ interface NavigationProps { keyboard?: boolean, actions?: ActionMap, scope?: string, - pollingInterval?: number - ref?: NavigationContextType; - overlap?: number + pollingInterval?: number, + ref?: NavigationRef, + overlap?: number, } const Navigation: ParentComponent = (props) => { @@ -38,36 +41,21 @@ const Navigation: ParentComponent = (props) => { keyboard: props.keyboard ?? true, actions: {...DEFAULT_ACTIONS, ...props.actions}, scope: props.scope ?? "", + navigationEnabled: false }) const areas = new Set(); - const { - addAction, - executeAction, - registerAction, - removeAction, - unregisterAction, - updateAction, - getScope, - getAction, - getActions, - isPaused, - pauseAction, - resumeAction - } = createActionMethods(config, setConfig) - const areaMethods = createAreaMethods(areas, setConfig); + // Create action methods and extract internal-only methods + const { registerAction, unregisterAction, ...publicActionMethods } = createActionMethods(config, setConfig); + + // Create area methods and extract internal-only methods + const { isEnabled, ...publicAreaMethods } = createAreaMethods(areas, config, setConfig); + + // Compose public API const navigationAPI = { - addAction, - removeAction, - executeAction, - updateAction, - getScope, - getAction, - getActions, - isPaused, - pauseAction, - resumeAction, - ...areaMethods, + ...publicActionMethods, + ...publicAreaMethods, + _navigationEnabled: isEnabled } const initActions = () => { @@ -90,7 +78,7 @@ const Navigation: ParentComponent = (props) => { initActions() eventBus.on('select', (scope: string) => console.log(scope)) if (!props.ref) return; - (props.ref as unknown as (ref: NavigationContextType) => void)(navigationAPI); + (props.ref as unknown as (ref: NavigationRef) => void)(navigationAPI); }) onCleanup(() => deInitActions()) diff --git a/src/components/Navigation/Navigation/NavigationArea.tsx b/src/components/Navigation/Navigation/NavigationArea.tsx index 2da5f9a..c13a37a 100644 --- a/src/components/Navigation/Navigation/NavigationArea.tsx +++ b/src/components/Navigation/Navigation/NavigationArea.tsx @@ -1,6 +1,5 @@ import { children, createEffect, on, onCleanup, onMount, ParentComponent, useContext } from "solid-js" import { NavigationContext } from "./Navigation"; - interface NavigationAreaProps { name: string, selector?: string, @@ -11,14 +10,28 @@ const NavigationArea: ParentComponent = (props) => { const context = useContext(NavigationContext); const cachedChildren = children(() => props.children); const navigatableElements = props.selector ? [`.${props.selector}`] : cachedChildren(); + let hasRegistered = false; const refresh = () => { - context!.unregisterArea(props.name); - context!.registerArea(props.name, navigatableElements as HTMLElement[], false); + if (!context!._navigationEnabled()) return; + deinit(); + init(false); + } + + const init = (focus: boolean) => { + context!.registerArea(props.name, navigatableElements as HTMLElement[], focus); } + const deinit = () => { + context!.unregisterArea(props.name); + }; + // Refresh whenever children change createEffect(on(cachedChildren, refresh, { defer: true })) + createEffect(on(context!._navigationEnabled, (v) => { + if (v && hasRegistered) init(false); + hasRegistered = true; + }, { defer: true })) onMount(() => { if (!context) { @@ -26,12 +39,11 @@ const NavigationArea: ParentComponent = (props) => { return null } - context!.registerArea(props.name, navigatableElements as HTMLElement[], props.focused ?? false); + const shouldFocus = props.focused || props.name === context.getScope(); + init(shouldFocus); }) - onCleanup(() => { - context!.unregisterArea(props.name); - }) + onCleanup(() => deinit()) return ( <>{cachedChildren()} diff --git a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts index c83fded..9a2e25c 100644 --- a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts @@ -17,8 +17,8 @@ export default function createActionMethods( actions.register(actionName, () => { if (isPaused(actionName)) return; - callback && callback(config.scope); - if (shouldEmitGlobally) eventBus.emit(actionName, config.scope); + callback && callback(getScope()); + if (shouldEmitGlobally) eventBus.emit(actionName, getScope()); }) if (config.keyboard && key) { diff --git a/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts b/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts index 15f624f..91e8222 100644 --- a/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts +++ b/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts @@ -53,4 +53,20 @@ export interface AreaMethods { * Resets navigation keys to their default values */ resetNavigationKeys: () => void; + + /** + * Checks if spatial navigation is currently enabled + * @returns True if navigation is enabled, false otherwise + */ + isEnabled: () => boolean; + + /** + * Pauses spatial navigation by deinitializing it + */ + pauseNavigation: () => void; + + /** + * Resumes spatial navigation by re-enabling it + */ + resumeNavigation: () => void; } diff --git a/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts b/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts index 5b46457..a2334a8 100644 --- a/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts +++ b/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts @@ -7,25 +7,63 @@ import { AreaMethods } from './areaMethods.types'; export default function createAreaMethods( areas: Set, + config: NavigationConfigType, setConfig: SetStoreFunction ): AreaMethods { + let lastActive: Element | null = null; + let lastArea: string | null = null; + const registerArea = (area: string, elements: string[] | HTMLElement[], focused?: boolean) => { + const enabled = spatialNavigation.enabled; + if (!enabled) setConfig('navigationEnabled', true) + waitForFrames(() => { - const enabled = spatialNavigation.enabled; spatialNavigation[enabled ? 'add' : 'init']([ { area: area, elements: elements }, ]); areas.add(area); focused && focusFirst(area); - }) + }, enabled ? 0 : 3) } const unregisterArea = (area: string) => { spatialNavigation.remove(area) areas.delete(area); - if (areas.size === 0) spatialNavigation.deinit(); + if (areas.size === 0) { + spatialNavigation.deinit(); + setConfig('navigationEnabled', false) + } + } + + const pauseNavigation = () => { + if (!isEnabled()) { + return console.warn('Spatial Navigation is not currently active!') + } + + lastActive = document.activeElement; + lastArea = config.scope; + spatialNavigation.deinit(); + setConfig('navigationEnabled', false) } + const resumeNavigation = () => { + if (isEnabled()) { + return console.warn('Spatial Navigation is already active!') + } + + setConfig('navigationEnabled', true) + waitForFrames(() => { + if (lastActive && spatialNavigation.isElementInGroup(lastActive)) { + (lastActive as HTMLElement).focus(); + setConfig('scope', lastArea!); + } else { + focusFirst(config.scope) + } + }) + } + + const isEnabled = () => config.navigationEnabled + const focusFirst = (area: string) => { if (!areas.has(area)) { console.warn(`Area "${area}" not registered. Available areas:`, Array.from(areas)); @@ -72,6 +110,9 @@ export default function createAreaMethods( switchArea, clearFocus, changeNavigationKeys, - resetNavigationKeys + resetNavigationKeys, + isEnabled, + pauseNavigation, + resumeNavigation }; } diff --git a/src/components/Navigation/Navigation/types.ts b/src/components/Navigation/Navigation/types.ts index d44b992..ce68763 100644 --- a/src/components/Navigation/Navigation/types.ts +++ b/src/components/Navigation/Navigation/types.ts @@ -1,3 +1,4 @@ +import { Accessor } from 'solid-js'; import { KeyBinding, GamepadButton } from './keybindings/keybindings.types'; type ActionType = 'press' | 'hold' | 'lift'; @@ -22,5 +23,6 @@ export interface NavigationConfigType { gamepad: boolean, keyboard: boolean, actions: ActionMap, - scope: string + scope: string, + navigationEnabled: boolean } \ No newline at end of file From 8c95e8c40a7f7d915df01d1fc6cd58c6bb969dda Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Tue, 25 Nov 2025 17:14:55 +0200 Subject: [PATCH 06/29] Add documentation and move component to Utility folder --- .../assets/components/utility/navigation.svg | 88 +++++ .../docs/components/Utility/Navigation.mdx | 340 ++++++++++++++++++ docs/src/content/docs/components/index.mdx | 1 + .../BaseComponent/BaseComponent.tsx | 11 +- .../Navigation/Navigation.tsx | 2 +- .../Navigation/NavigationArea.tsx | 19 +- .../actionMethods/actionMethods.types.ts | 0 .../actionMethods/useActionMethods.ts | 0 .../areaMethods/areaMethods.types.ts | 0 .../Navigation/areaMethods/useAreaMethods.ts | 0 .../Navigation/defaults.ts | 0 .../keybindings/keybindings.types.ts | 17 - .../Navigation/types.ts | 1 - src/components/types/ComponentProps.d.ts | 2 +- 14 files changed, 447 insertions(+), 34 deletions(-) create mode 100644 docs/src/assets/components/utility/navigation.svg create mode 100644 docs/src/content/docs/components/Utility/Navigation.mdx rename src/components/{Navigation => Utility}/Navigation/Navigation.tsx (97%) rename src/components/{Navigation => Utility}/Navigation/NavigationArea.tsx (65%) rename src/components/{Navigation => Utility}/Navigation/actionMethods/actionMethods.types.ts (100%) rename src/components/{Navigation => Utility}/Navigation/actionMethods/useActionMethods.ts (100%) rename src/components/{Navigation => Utility}/Navigation/areaMethods/areaMethods.types.ts (100%) rename src/components/{Navigation => Utility}/Navigation/areaMethods/useAreaMethods.ts (100%) rename src/components/{Navigation => Utility}/Navigation/defaults.ts (100%) rename src/components/{Navigation => Utility}/Navigation/keybindings/keybindings.types.ts (88%) rename src/components/{Navigation => Utility}/Navigation/types.ts (95%) diff --git a/docs/src/assets/components/utility/navigation.svg b/docs/src/assets/components/utility/navigation.svg new file mode 100644 index 0000000..dea27dc --- /dev/null +++ b/docs/src/assets/components/utility/navigation.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/content/docs/components/Utility/Navigation.mdx b/docs/src/content/docs/components/Utility/Navigation.mdx new file mode 100644 index 0000000..eee3007 --- /dev/null +++ b/docs/src/content/docs/components/Utility/Navigation.mdx @@ -0,0 +1,340 @@ +--- +title: Navigation +tableOfContents: + maxHeadingLevel: 4 +--- +import { Steps } from '@astrojs/starlight/components'; + +The `Navigation` component is a comprehensive navigation system designed for game UIs. +It provides keyboard and gamepad input handling, spatial navigation between UI areas, and a flexible action system for mapping inputs to callbacks. +Built on top of the [Coherent Gameface Interaction Manager](https://frontend-tools.coherent-labs.com/interaction-manager/getting-started/), +it enables seamless navigation across complex menu systems and HUD elements. + +This component provides: + +- Keyboard and gamepad input management with customizable bindings +- Spatial navigation with multiple navigation areas +- Action system for mapping inputs to game-specific callbacks +- Scope-based context awareness for different UI sections +- Pause/resume functionality for navigation state +- Integration with components via context and navigation actions directive + +## Usage + +To use the `Navigation` component, wrap your UI with the it and define navigation areas for different sections of your UI. You can configure default actions and provide custom input bindings for your game. + +:::tip +For detailed usage instructions, examples, and advanced features, refer to the [Guide](/components/utility/navigation#guide) section. +::: + +```tsx +import Navigation from '@components/Navigation/Navigation/Navigation'; +import { ActionMap } from '@components/Navigation/Navigation/types'; + +const App = () => { + const defaultActions: ActionMap = { + 'tab-left': {key: {binds: ['Q'], type: ['press']}, button: {binds: ['left-shoulder'], type: 'press'}, callback: menuLeft, global: true}, + 'tab-right': {key: {binds: ['E'], type: ['press']}, button: {binds: ['right-shoulder'], type: 'press'}, callback: menuRight, global: true}, + 'select': {key: {binds: ['SPACE'], type: ['press']}, button: {binds: ['face-button-left'], type: 'press'}}, + 'back': {key: {binds: ['BACKSPACE'], type: ['press']}}, + } + return ( + + + + + + + + ); +}; + +export default App; +``` + +## Default Actions + +The Navigation component comes with six pre-configured default actions that handle common navigation patterns in game UIs. These actions are automatically registered when the Navigation component mounts and emit globally, making them accessible to any component within your application. + +Default actions are constant, pre-defined actions that provide standard input mappings for navigation and interaction. +These actions are recognized across the entire Gameface UI component library, enabling preset components to work seamlessly with the Navigation system out of the box. + +Each default action has: +- **Keyboard bindings** - One or more keyboard keys +- **Gamepad bindings** - One or more gamepad buttons +- **Global emission** - They emit events via the [event bus](/concepts/ui-communication/), allowing any component to respond +- **No default callbacks** - They don't execute callbacks by default, only emit events. However they can be extended to execute callback as well. +- **Library-wide recognition** - Preset components like Dropdown, Stepper, Checkbox, and others automatically listen to these actions + +### Available Default Actions + +| Action Name | Keyboard Binding | Gamepad Binding | Purpose | +|---------------|------------------|----------------------|----------------------------------------------------------------------------------| +| `move-left` | `ARROW_LEFT` | `pad-left` (D-Pad) | Directional input left (used by components Stepper) | +| `move-right` | `ARROW_RIGHT` | `pad-right` (D-Pad) | Directional input right (used by components Stepper) | +| `move-up` | `ARROW_UP` | `pad-up` (D-Pad) | Directional input up (used by components like Dropdown) | +| `move-down` | `ARROW_DOWN` | `pad-down` (D-Pad) | Directional input down (used by components like Dropdown) | +| `select` | `ENTER` | `face-button-down` | Confirm selection or activate the currently focused element | +| `back` | `ESC` | `face-button-right` | Cancel current operation or navigate back to the previous screen | + +:::caution[Reserved Action Names] +Avoid creating custom actions with names starting with `move-focus-*` (e.g., `move-focus-left`, `move-focus-right`, etc.). These names are reserved and used internally by the spatial navigation system for managing focus between UI elements. +::: + +### Customizing Default Actions + +Default actions can be customized by updating their configuration. This is useful when you want to: +- Change keyboard/gamepad bindings to match your game's control scheme +- Add custom callbacks to default actions +- Modify input types (press, hold, lift) + +You can customize default actions either through the `actions` prop or programmatically using `updateAction()`: + +**Via props:** +```tsx +import Navigation from '@components/Navigation/Navigation/Navigation'; +import { ActionMap } from '@components/Navigation/Navigation/types'; + +const customActions: ActionMap = { + // Customize the 'select' action to use SPACE instead of ENTER + 'select': { + key: {binds: ['SPACEBAR'], type: ['press']}, + button: {binds: ['face-button-down'], type: 'press'}, + callback: (scope) => console.log('Selected in', scope), + }, + // Customize 'back' to add a callback + 'back': { + key: {binds: ['ESC'], type: ['press']}, + button: {binds: ['face-button-right'], type: 'press'}, + callback: () => handleBack(), + } +}; + + + {/* Your UI */} + +``` + +**Programmatically:** +```tsx +const nav = useNavigation(); + +// Update the select action at runtime +nav.updateAction('select', { + key: {binds: ['SPACEBAR'], type: ['press']}, + button: {binds: ['face-button-down'], type: 'press'}, + callback: (scope) => handleSelection(scope), + global: true +}); +``` + +### Adding Custom Actions + +Beyond the default actions, you can add your own custom actions for game-specific inputs: + +```tsx +import Navigation from '@components/Navigation/Navigation/Navigation'; +import { ActionMap } from '@components/Navigation/Navigation/types'; + +const customActions: ActionMap = { + // Custom action for opening inventory + 'open-inventory': { + key: {binds: ['I'], type: ['press']}, + button: {binds: ['face-button-top'], type: 'press'}, + callback: () => openInventory(), + global: true + }, + // Custom action for opening map + 'open-map': { + key: {binds: ['M'], type: ['press']}, + button: {binds: ['face-button-left'], type: 'press'}, + callback: (scope) => openMap(scope), + global: false // Only emit locally, not globally + } +}; + + + {/* Your UI */} + +``` + +Or add them programmatically: + +```tsx +const nav = useNavigation(); + +nav.addAction('toggle-menu', { + key: {binds: ['TAB'], type: ['press']}, + button: {binds: ['START'], type: 'press'}, + callback: () => toggleMenu(), + global: true +}); +``` + +### Subscribing to Actions in Custom Components + +Custom components can respond to navigation actions using the `navigationActions` directive from `BaseComponent`. +This directive automatically listens for action events and executes callbacks when the component (or its anchor) is focused. + +To subscribe your component to an action, follow these steps: + + + +1. Import the `useBaseComponent` hook in your component + +2. Extract the `navigationActions` method from the hook + +3. Add the `navigationActions` method as an attribute to the element you wish to subscribe, and prefix it with the `use:` keyword + +4. Provide the names of the actions you wish to subscribe to and their corresponding callbacks (what will happen when they're triggered) + + + +```tsx +import { useBaseComponent } from '@components/BaseComponent/BaseComponent'; +const MyComponent = (props) => { + const { navigationActions } = useBaseComponent(props); + return ( +
console.log('Item selected'), + back: () => console.log('Going back') + }}> +
+ ); +}; +``` + +:::caution +Only actions with `global: true` (including all default actions) can be subscribed to using the `navigationActions` directive. Actions with `global: false` only execute their callbacks and don't emit events for components to listen to. +::: + +**With an anchor element:** + +Sometimes you want a component to respond to actions when a different element is focused (like a child button): + +```tsx +const Card = (props) => { + let buttonRef; + const { navigationActions } = useBaseComponent(props); + + return ( +
handleCardSelect(), + 'custom-action': () => handleCustomAction() + }} + > + +
Card content
+
+ ); +}; +``` + +:::tip +The `navigationActions` directive only triggers callbacks when the component or its anchor is focused. This ensures actions are contextual and don't fire globally for all components. +::: + +### Action Configuration Reference (ActionCfg) + +When defining actions, you can configure the following properties: + +| Property | Type | Description | +|------------|-------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `key` | `{binds: KeyBinding[], type?: ActionType[]}` | Keyboard configuration. `binds` specifies which keys trigger the action. `type` specifies when to trigger: `'press'` (default), `'hold'`, or `'lift'`. | +| `button` | `{binds: GamepadButton[], type?: ActionType}` | Gamepad configuration. `binds` specifies which buttons trigger the action. `type` specifies when to trigger: `'press'` (default) or `'hold'`. | +| `callback` | `(scope?: string, ...args: any[]) => void` | Function to execute when the action is triggered. Receives the current navigation scope as the first parameter. | +| `global` | `boolean` | When `true`, the action emits globally via the [eventBus](/concepts/ui-communication/), allowing any component to listen. When `false`, only the callback is executed. Default actions have this set to `true`. | + +## API + +### Props + +| Prop Name | Type | Default | Description | +|-------------------|-------------------------------|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `gamepad` | `boolean` | `true` | Enables gamepad input handling for navigation and actions. | +| `keyboard` | `boolean` | `true` | Enables keyboard input handling for navigation and actions. | +| `actions` | `ActionMap` | `{}` | Custom action configurations to register in addition to default actions. Each action can define keyboard/gamepad bindings and callbacks. | +| `scope` | `string` | `""` | The initial navigation scope. When a `Navigation.Area` with a matching name is registered, it will be auto-focused. | +| `pollingInterval` | `number` | `200` | Gamepad polling interval in milliseconds. Determines how frequently the system checks for gamepad input. | +| `ref` | `NavigationRef` | `undefined` | A reference to the component, providing access to all navigation methods via the [NavigationRef](/components/utility/navigation#contextref-api) interface. | +| `overlap` | `number` | `undefined` | Overlap threshold for spatial navigation. Determines how much elements can overlap before being considered in different navigation paths. | + +## UseNavigation hook & Ref API + +The `Navigation` component exposes a comprehensive API through the `useNavigation()` hook (via context) or through a `ref`. This API provides methods for managing navigation areas, handling actions, and controlling navigation state. + +### Action Methods + +| Method Name | Parameters | Return Value | Description | +|-----------------|------------------------------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `addAction` | `name: ActionName, config:` [ActionCfg](/components/utility/navigation#action-configuration-reference-actioncfg) | `void` | Adds a new `Navigation` action and registers it with the interaction manager. | +| `removeAction` | `name: ActionName` | `void` | Removes a registered `Navigation` action and unregisters it from the interaction manager. | +| `updateAction` | `name: ActionName, config:` [ActionCfg](/components/utility/navigation#action-configuration-reference-actioncfg) | `void` | Updates an existing action's configuration. Default actions can be updated as well. Accepts the same config properties as `addAction`. | +| `executeAction` | `name: ActionName` | `void` | Executes a registered action by name, triggering its callback and event emission. | +| `pauseAction` | `action: ActionName` | `void` | Pauses an action, preventing its callback from executing. | +| `resumeAction` | `action: ActionName` | `void` | Resumes a paused action, allowing its callback to execute again. | +| `isPaused` | `action: ActionName` | `boolean` | Checks if an action is currently paused. Returns true if paused, false otherwise. | +| `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. | + +### Area Methods + +| Method Name | Parameters | Return Value | Description | +|------------------------|----------------------------------------------------------------------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `registerArea` | `area: string, elements: string[] \| HTMLElement[], focused?: boolean` | `void` | Registers a navigation area with focusable elements. If `focused` is true, automatically focuses the first element in the area. | +| `unregisterArea` | `area: string` | `void` | Unregisters a navigation area and removes it from spatial navigation. | +| `focusFirst` | `area: string` | `void` | Focuses the first focusable element in the specified area and updates the navigation `scope`. | +| `focusLast` | `area: string` | `void` | Focuses the last focusable element in the specified area and updates the navigation `scope`. | +| `switchArea` | `area: string` | `void` | Switches the active navigation to the specified area by focusing the first element in it and updating the navigation `scope`. | +| `clearFocus` | None | `void` | Clears the current focus from all navigation areas. | +| `changeNavigationKeys` | `keys: { up?: string, down?: string, left?: string, right?: string }, clearCurrent?: boolean` | `void` | Changes the navigation keys for spatial navigation. If `clearCurrent` is true, clears current active keys before setting new ones. | +| `resetNavigationKeys` | None | `void` | Resets navigation keys to their default values. | +| `pauseNavigation` | None | `void` | Pauses spatial navigation by deinitializing it. Saves the currently focused element for restoration on resume. | +| `resumeNavigation` | None | `void` | Resumes spatial navigation by re-enabling it. Attempts to restore focus to the previously focused element, or focuses the first element of the current `scope` if restoration fails. | + +## Slots + +The `Navigation` component exposes subcomponents that allow you to structure your navigation: + +- [**Navigation.Area**](/components/utility/navigation/#navigationarea) - Defines a navigation area with focusable elements. + +### Navigation.Area + +`Navigation.Area` registers a section of your UI as a navigable area. Elements within the area can be navigated using keyboard/gamepad inputs. Areas can be switched programmatically. + +When a `Navigation.Area` mounts: +- It registers itself with the spatial navigation system +- If its `name` matches the parent Navigation's `scope` prop or if `focused={true}`, it auto-focuses +- It automatically handles cleanup on unmount +- It re-registers when navigation is resumed after being paused + +#### Navigation.Area Props + +| Prop Name | Type | Default | Description | +| ---------- | -------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | *required* | The unique name identifier for this navigation area. Used to reference the area in methods like `focusFirst`, `switchArea`, and for `scope` tracking. | +| `selector` | `string` | `undefined` | CSS class selector for navigable elements. If provided, only elements matching this selector will be navigable. If omitted, all child elements are considered navigable. | +| `focused` | `boolean` | `false` | When true, this area will automatically receive focus when it mounts, focusing its first navigable element. | + +#### Usage + +```tsx + + + + + + + + +
Resolution
+
Quality
+
V-Sync
+
+
+``` + +## Guide \ No newline at end of file diff --git a/docs/src/content/docs/components/index.mdx b/docs/src/content/docs/components/index.mdx index 6a95543..d02606b 100644 --- a/docs/src/content/docs/components/index.mdx +++ b/docs/src/content/docs/components/index.mdx @@ -30,6 +30,7 @@ export const componentCategories = { Complex: Object.keys(import.meta.glob('/src/assets/components/complex/*')), Layout: Object.keys(import.meta.glob('/src/assets/components/layout/*')), Media: Object.keys(import.meta.glob('/src/assets/components/media/*')), + Utility: Object.keys(import.meta.glob('/src/assets/components/utility/*')) }; export function getCategorySlug(title) { diff --git a/src/components/BaseComponent/BaseComponent.tsx b/src/components/BaseComponent/BaseComponent.tsx index 9472374..81e9ca3 100644 --- a/src/components/BaseComponent/BaseComponent.tsx +++ b/src/components/BaseComponent/BaseComponent.tsx @@ -1,8 +1,10 @@ -import { useNavigation } from "@components/Navigation/Navigation/Navigation"; +import { useNavigation } from "@components/Utility/Navigation/Navigation"; import eventBus from "@components/tools/EventBus"; import { ComponentProps, NavigationActionsConfig } from "@components/types/ComponentProps"; import { Accessor, createEffect } from "solid-js"; import { waitForFrames } from "@components/utils/waitForFrames"; +import { DEFAULT_ACTION_NAMES } from "@components/Utility/Navigation/defaults"; +import { DefaultActions } from "@components/Utility/Navigation/types"; const baseEventsSet = new Set([ "abort", @@ -115,7 +117,12 @@ function navigationActions(el: HTMLElement, accessor: Accessor = (props) => { gamepad.pollingInterval = props.pollingInterval ?? 200; } initActions() - eventBus.on('select', (scope: string) => console.log(scope)) + if (!props.ref) return; (props.ref as unknown as (ref: NavigationRef) => void)(navigationAPI); }) diff --git a/src/components/Navigation/Navigation/NavigationArea.tsx b/src/components/Utility/Navigation/NavigationArea.tsx similarity index 65% rename from src/components/Navigation/Navigation/NavigationArea.tsx rename to src/components/Utility/Navigation/NavigationArea.tsx index c13a37a..adab151 100644 --- a/src/components/Navigation/Navigation/NavigationArea.tsx +++ b/src/components/Utility/Navigation/NavigationArea.tsx @@ -1,5 +1,5 @@ import { children, createEffect, on, onCleanup, onMount, ParentComponent, useContext } from "solid-js" -import { NavigationContext } from "./Navigation"; +import { NavigationContext, useNavigation } from "./Navigation"; interface NavigationAreaProps { name: string, selector?: string, @@ -7,39 +7,34 @@ interface NavigationAreaProps { } const NavigationArea: ParentComponent = (props) => { - const context = useContext(NavigationContext); + const nav = useNavigation(); const cachedChildren = children(() => props.children); const navigatableElements = props.selector ? [`.${props.selector}`] : cachedChildren(); let hasRegistered = false; const refresh = () => { - if (!context!._navigationEnabled()) return; + if (!nav._navigationEnabled()) return; deinit(); init(false); } const init = (focus: boolean) => { - context!.registerArea(props.name, navigatableElements as HTMLElement[], focus); + nav.registerArea(props.name, navigatableElements as HTMLElement[], focus); } const deinit = () => { - context!.unregisterArea(props.name); + nav.unregisterArea(props.name); }; // Refresh whenever children change createEffect(on(cachedChildren, refresh, { defer: true })) - createEffect(on(context!._navigationEnabled, (v) => { + createEffect(on(nav!._navigationEnabled, (v) => { if (v && hasRegistered) init(false); hasRegistered = true; }, { defer: true })) onMount(() => { - if (!context) { - console.warn('No context bro'); - return null - } - - const shouldFocus = props.focused || props.name === context.getScope(); + const shouldFocus = props.focused || props.name === nav.getScope(); init(shouldFocus); }) diff --git a/src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts b/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts similarity index 100% rename from src/components/Navigation/Navigation/actionMethods/actionMethods.types.ts rename to src/components/Utility/Navigation/actionMethods/actionMethods.types.ts diff --git a/src/components/Navigation/Navigation/actionMethods/useActionMethods.ts b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts similarity index 100% rename from src/components/Navigation/Navigation/actionMethods/useActionMethods.ts rename to src/components/Utility/Navigation/actionMethods/useActionMethods.ts diff --git a/src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts similarity index 100% rename from src/components/Navigation/Navigation/areaMethods/areaMethods.types.ts rename to src/components/Utility/Navigation/areaMethods/areaMethods.types.ts diff --git a/src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts similarity index 100% rename from src/components/Navigation/Navigation/areaMethods/useAreaMethods.ts rename to src/components/Utility/Navigation/areaMethods/useAreaMethods.ts diff --git a/src/components/Navigation/Navigation/defaults.ts b/src/components/Utility/Navigation/defaults.ts similarity index 100% rename from src/components/Navigation/Navigation/defaults.ts rename to src/components/Utility/Navigation/defaults.ts diff --git a/src/components/Navigation/Navigation/keybindings/keybindings.types.ts b/src/components/Utility/Navigation/keybindings/keybindings.types.ts similarity index 88% rename from src/components/Navigation/Navigation/keybindings/keybindings.types.ts rename to src/components/Utility/Navigation/keybindings/keybindings.types.ts index 68fb17f..cdd4027 100644 --- a/src/components/Navigation/Navigation/keybindings/keybindings.types.ts +++ b/src/components/Utility/Navigation/keybindings/keybindings.types.ts @@ -111,23 +111,6 @@ export type KeyBinding = * Based on coherent-gameface-interaction-manager gamepad-key-codes */ export type GamepadButton = - | 'FACE_BUTTON_DOWN' - | 'FACE_BUTTON_RIGHT' - | 'FACE_BUTTON_LEFT' - | 'FACE_BUTTON_TOP' - | 'LEFT_SHOULDER' - | 'RIGHT_SHOULDER' - | 'LEFT_SHOULDER_BOTTOM' - | 'RIGHT_SHOULDER_BOTTOM' - | 'SELECT' - | 'START' - | 'LEFT_ANALOGUE_STICK' - | 'RIGHT_ANALOGUE_STICK' - | 'PAD_UP' - | 'PAD_DOWN' - | 'PAD_LEFT' - | 'PAD_RIGHT' - | 'CENTER_BUTTON' | 'face-button-down' | 'face-button-left' | 'face-button-right' diff --git a/src/components/Navigation/Navigation/types.ts b/src/components/Utility/Navigation/types.ts similarity index 95% rename from src/components/Navigation/Navigation/types.ts rename to src/components/Utility/Navigation/types.ts index ce68763..fa84630 100644 --- a/src/components/Navigation/Navigation/types.ts +++ b/src/components/Utility/Navigation/types.ts @@ -1,4 +1,3 @@ -import { Accessor } from 'solid-js'; import { KeyBinding, GamepadButton } from './keybindings/keybindings.types'; type ActionType = 'press' | 'hold' | 'lift'; diff --git a/src/components/types/ComponentProps.d.ts b/src/components/types/ComponentProps.d.ts index 2412b3b..2f78dfc 100644 --- a/src/components/types/ComponentProps.d.ts +++ b/src/components/types/ComponentProps.d.ts @@ -1,6 +1,6 @@ import { JSX, ParentProps } from "solid-js"; import Events from "./BaseComponent"; -import { ActionName } from "@components/Navigation/Navigation/types"; +import { ActionName } from "@components/Utility/Navigation/types"; type ExcludedEvents = | "abort" From c99a48cb6e000554d4ea94c15f7ccd980e58e193 Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Tue, 25 Nov 2025 17:24:47 +0200 Subject: [PATCH 07/29] Add demo integration in the Menu UI for testing --- .../BaseComponent/BaseComponent.tsx | 2 +- src/components/Basic/Stepper/Stepper.tsx | 7 +- .../{tools => Utility}/EventBus/index.ts | 160 ++++++------- .../Utility/Navigation/Navigation.tsx | 2 +- .../actionMethods/useActionMethods.ts | 2 +- src/components/types/ComponentProps.d.ts | 1 + .../Menu/MenuItem/MenuItem.tsx | 9 +- .../Menu/Options/Audio/Audio.tsx | 2 +- .../Menu/Options/Gameplay/Gameplay.tsx | 53 ++-- .../Menu/Options/Graphics/Graphics.tsx | 5 +- src/views/menu/Menu.tsx | 226 ++++++++++-------- src/views/menu/util/index.ts | 2 +- 12 files changed, 259 insertions(+), 212 deletions(-) rename src/components/{tools => Utility}/EventBus/index.ts (96%) diff --git a/src/components/BaseComponent/BaseComponent.tsx b/src/components/BaseComponent/BaseComponent.tsx index 81e9ca3..eab5e64 100644 --- a/src/components/BaseComponent/BaseComponent.tsx +++ b/src/components/BaseComponent/BaseComponent.tsx @@ -1,5 +1,5 @@ import { useNavigation } from "@components/Utility/Navigation/Navigation"; -import eventBus from "@components/tools/EventBus"; +import eventBus from "@components/Utility/EventBus"; import { ComponentProps, NavigationActionsConfig } from "@components/types/ComponentProps"; import { Accessor, createEffect } from "solid-js"; import { waitForFrames } from "@components/utils/waitForFrames"; diff --git a/src/components/Basic/Stepper/Stepper.tsx b/src/components/Basic/Stepper/Stepper.tsx index 2bf5377..adb7dae 100644 --- a/src/components/Basic/Stepper/Stepper.tsx +++ b/src/components/Basic/Stepper/Stepper.tsx @@ -98,7 +98,7 @@ const Stepper: ParentComponent = (props) => { } props.componentClasses = () => stepperClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); const setOption = (value: string) => { if (props.disabled) return; @@ -154,6 +154,11 @@ const Stepper: ParentComponent = (props) => { style={inlineStyles()} use:forwardEvents={props} use:forwardAttrs={props} + use:navigationActions={{ + anchor: props.anchor, + 'move-left': () => changeSelected('prev'), + 'move-right': () => changeSelected('next') + }} > diff --git a/src/components/tools/EventBus/index.ts b/src/components/Utility/EventBus/index.ts similarity index 96% rename from src/components/tools/EventBus/index.ts rename to src/components/Utility/EventBus/index.ts index 56dfe70..b33fa89 100644 --- a/src/components/tools/EventBus/index.ts +++ b/src/components/Utility/EventBus/index.ts @@ -1,81 +1,81 @@ -type LogLevel = 'warn' | 'none'; - -interface EventBus { - _logLevel: LogLevel; - registeredEvents: Record>; -} - -type EventCallback = (...args: any[]) => any; - -class EventBus { - constructor() { - this._logLevel = 'warn'; - this.registeredEvents = {}; - } - - get logLevel() { return this._logLevel; } - - set logLevel(level: LogLevel) { - this._logLevel = level; - } - - warn(message: string) { - if (this.logLevel !== 'warn') return; - - console.warn(`[EventBus] ${message}`); - } - - on(event: string, callback: EventCallback) { - if (!this.registeredEvents[event]) { - this.registeredEvents[event] = new Set(); - } - - if (this.registeredEvents[event].has(callback)) { - this.warn(`Registering again. Callback is already registered for the ${event} event.`); - return; - } - - this.registeredEvents[event].add(callback); - } - - off(event: string, callback: EventCallback) { - const eventSet = this.registeredEvents[event]; - - if (!eventSet) { - this.warn(`Nothing to remove. No callbacks registered for the ${event} event.`); - return; - } - - if (eventSet.has(callback)) eventSet.delete(callback); - if (eventSet.size === 0) delete this.registeredEvents[event]; - } - - emit(event: string, ...args: any[]) { - const eventSet = this.registeredEvents[event]; - - if (!eventSet) { - this.warn(`Emitting event with no listeners. No callbacks registered for the ${event} event.`); - return; - } - - eventSet.forEach((cb) => cb(...args)); - } - - once(event: string, callback: EventCallback) { - const onceCallback = (...args: any[]) => { - callback(...args); - this.off(event, onceCallback); - }; - - this.on(event, onceCallback); - } - - hasRegistered(event: string, callback?: EventCallback) { - if (!callback) return this.registeredEvents[event] !== undefined; - - return this.registeredEvents[event]?.has(callback); - } -} - -const eventBus = new EventBus(); +type LogLevel = 'warn' | 'none'; + +interface EventBus { + _logLevel: LogLevel; + registeredEvents: Record>; +} + +type EventCallback = (...args: any[]) => any; + +class EventBus { + constructor() { + this._logLevel = 'warn'; + this.registeredEvents = {}; + } + + get logLevel() { return this._logLevel; } + + set logLevel(level: LogLevel) { + this._logLevel = level; + } + + warn(message: string) { + if (this.logLevel !== 'warn') return; + + console.warn(`[EventBus] ${message}`); + } + + on(event: string, callback: EventCallback) { + if (!this.registeredEvents[event]) { + this.registeredEvents[event] = new Set(); + } + + if (this.registeredEvents[event].has(callback)) { + this.warn(`Registering again. Callback is already registered for the ${event} event.`); + return; + } + + this.registeredEvents[event].add(callback); + } + + off(event: string, callback: EventCallback) { + const eventSet = this.registeredEvents[event]; + + if (!eventSet) { + this.warn(`Nothing to remove. No callbacks registered for the ${event} event.`); + return; + } + + if (eventSet.has(callback)) eventSet.delete(callback); + if (eventSet.size === 0) delete this.registeredEvents[event]; + } + + emit(event: string, ...args: any[]) { + const eventSet = this.registeredEvents[event]; + + if (!eventSet) { + this.warn(`Emitting event with no listeners. No callbacks registered for the ${event} event.`); + return; + } + + eventSet.forEach((cb) => cb(...args)); + } + + once(event: string, callback: EventCallback) { + const onceCallback = (...args: any[]) => { + callback(...args); + this.off(event, onceCallback); + }; + + this.on(event, onceCallback); + } + + hasRegistered(event: string, callback?: EventCallback) { + if (!callback) return this.registeredEvents[event] !== undefined; + + return this.registeredEvents[event]?.has(callback); + } +} + +const eventBus = new EventBus(); export default eventBus; \ No newline at end of file diff --git a/src/components/Utility/Navigation/Navigation.tsx b/src/components/Utility/Navigation/Navigation.tsx index 32c5bf5..246591a 100644 --- a/src/components/Utility/Navigation/Navigation.tsx +++ b/src/components/Utility/Navigation/Navigation.tsx @@ -2,7 +2,7 @@ import { Accessor, createContext, onCleanup, onMount, ParentComponent, useContex // @ts-ignore import { gamepad } from 'coherent-gameface-interaction-manager'; import NavigationArea from "./NavigationArea"; -import eventBus from "@components/tools/EventBus"; +import eventBus from "@components/Utility/EventBus"; import { createStore } from "solid-js/store"; import { ActionMap, NavigationConfigType } from "./types"; import { DEFAULT_ACTIONS } from "./defaults"; diff --git a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts index 9a2e25c..322867d 100644 --- a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts @@ -3,7 +3,7 @@ import { actions, keyboard, gamepad } from 'coherent-gameface-interaction-manage import { SetStoreFunction } from 'solid-js/store'; import { ActionName, ActionCfg, DefaultActions, NavigationConfigType } from '../types'; import { DEFAULT_ACTION_NAMES } from '../defaults'; -import eventBus from '@components/tools/EventBus'; +import eventBus from '@components/Utility/EventBus'; import { ActionMethods } from './actionMethods.types'; export default function createActionMethods( diff --git a/src/components/types/ComponentProps.d.ts b/src/components/types/ComponentProps.d.ts index 2f78dfc..f366aea 100644 --- a/src/components/types/ComponentProps.d.ts +++ b/src/components/types/ComponentProps.d.ts @@ -32,6 +32,7 @@ export interface ComponentProps = {}> extends Comp componentClasses?: string | (() => string) ref?: unknown | ((ref: BaseComponentRef & T) => void); refObject?: T; + anchor?: HTMLElement | string } export interface TokenComponentProps { diff --git a/src/custom-components/Menu/MenuItem/MenuItem.tsx b/src/custom-components/Menu/MenuItem/MenuItem.tsx index e610f7e..b395c80 100644 --- a/src/custom-components/Menu/MenuItem/MenuItem.tsx +++ b/src/custom-components/Menu/MenuItem/MenuItem.tsx @@ -13,12 +13,17 @@ const MenuItem: ParentComponent = (props) => { const menuContext = useContext(MenuContext) const isActive = createMemo(() => menuContext?.currentOption() === props.id); - const handleMouseEnter = () => { + const handleFocus = () => { menuContext?.setCurrentOption(props.id); } + const handleMouseEnter = (event: MouseEvent) => { + const element = event.currentTarget; + (element as HTMLDivElement).focus(); + } + return ( - + {props.name} diff --git a/src/custom-components/Menu/Options/Audio/Audio.tsx b/src/custom-components/Menu/Options/Audio/Audio.tsx index 0267191..36631d4 100644 --- a/src/custom-components/Menu/Options/Audio/Audio.tsx +++ b/src/custom-components/Menu/Options/Audio/Audio.tsx @@ -5,7 +5,7 @@ import ExtraContent from "@custom-components/Menu/SidePanel/ExtraContent"; import CustomList from "@custom-components/Menu/CustomList/CustomList"; import { keybindPresetContent } from "@custom-components/Menu/SidePanel/keybindsPanelContent"; import KeyBind from "@custom-components/Menu/KeyBind/KeyBind"; -import eventBus from "@components/tools/EventBus"; +import eventBus from "@components/Utility/EventBus"; import { SegmentRef } from "@components/Basic/Segment/Segment"; import Dropdown from "@components/Basic/Dropdown/Dropdown"; import Checkbox from "@components/Basic/Checkbox/Checkbox"; diff --git a/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx b/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx index 11085c1..a319a38 100644 --- a/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx +++ b/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx @@ -11,37 +11,38 @@ import { emitChange } from "../../../../views/menu/util"; import Tutorial from "@components/Complex/Tutorial/Tutorial"; import { MenuContext } from "../../../../views/menu/Menu"; import { TutorialSteps } from "../../../../views/menu/util/tutorialSteps"; +import Navigation from "@components/Utility/Navigation/Navigation"; const Gameplay: ParentComponent = () => { - const [showSubtitleOptions, setShowSubtitleOptions] = createSignal(false); + const [showSubtitleOptions, setShowSubtitleOptions] = createSignal(true); const context = useContext(MenuContext); return ( - <> -
- - - - Easy - Normal - Hard - Nightmare - - - + + + + + Easy + Normal + Hard + Nightmare + + + + + + + + { + setShowSubtitleOptions(checked) + }} /> + - - - - setShowSubtitleOptions(checked)} /> - - - -
+ @@ -85,7 +86,7 @@ const Gameplay: ParentComponent = () => { - + ) } diff --git a/src/custom-components/Menu/Options/Graphics/Graphics.tsx b/src/custom-components/Menu/Options/Graphics/Graphics.tsx index a0d563f..a888a89 100644 --- a/src/custom-components/Menu/Options/Graphics/Graphics.tsx +++ b/src/custom-components/Menu/Options/Graphics/Graphics.tsx @@ -7,6 +7,7 @@ import CustomDropdown from "@custom-components/Menu/CustomDropdown/CustomDropdow import CustomSegment from "@custom-components/Menu/CustomSegment/CustomSegment"; import CustomNumberInput from "@custom-components/Menu/CustomNumberInput/CustomNumberInput"; import GraphicsPreset from "./GraphicsPreset"; +import Navigation from "@components/Utility/Navigation/Navigation"; const RESOLUTIONS = [ { value: "1280x720", label: "1280x720 (HD)" }, @@ -23,7 +24,7 @@ const RESOLUTIONS = [ const Graphics: ParentComponent = () => { return ( - <> + @@ -48,7 +49,7 @@ const Graphics: ParentComponent = () => { - + ) } diff --git a/src/views/menu/Menu.tsx b/src/views/menu/Menu.tsx index 78d1380..3885ce2 100644 --- a/src/views/menu/Menu.tsx +++ b/src/views/menu/Menu.tsx @@ -20,13 +20,15 @@ import Audio from '@custom-components/Menu/Options/Audio/Audio'; import Credits from '@custom-components/Menu/Options/Credits/Credits'; import CustomModal from '@custom-components/Menu/CustomModal/CustomModal'; import { ModalRef } from '@components/Feedback/Modal/Modal'; -import eventBus from '@components/tools/EventBus'; +import eventBus from '@components/Utility/EventBus'; import KeyBindsTab from '@custom-components/Menu/Options/KeyBindsTab/KeyBindsTab'; import useToast from '@components/Feedback/Toast/toast'; import Tutorial, { TutorialRef } from '@components/Complex/Tutorial/Tutorial'; import CustomToast from '@custom-components/Menu/CustomToast/CustomToast'; import CustomTooltip from '@custom-components/Menu/CustomTooltip/CustomTooltip'; import { TutorialSteps } from './util/tutorialSteps'; +import Navigation, { NavigationRef } from '@components/Utility/Navigation/Navigation'; +import { ActionMap } from '@components/Utility/Navigation/types'; interface MenuContextValue { currentOption: Accessor, @@ -42,6 +44,7 @@ export const OPTIONS = ['Gameplay', 'Graphics', 'Keybinds', 'Audio', 'Credits'] const Menu = () => { let modalRef!: ModalRef; let tutorialRef: TutorialRef | undefined + let navigationRef: NavigationRef | undefined; const [Toaster, createToast] = useToast(); const [currentOption, setCurrentOption] = createSignal('difficulty'); const [hasChanges, setHasChanges] = createSignal(false); @@ -123,106 +126,137 @@ const Menu = () => { } } + const menuRight = () => tabsRef.changeTab('Graphics'); // currently no way to "cycle" tabs + const menuLeft = () => tabsRef.changeTab('Gameplay'); + const testFunc = (arg1: number, arg2: number) => { + console.log(arg1, arg2); + } + + // User added + const defaultActions: ActionMap = { + 'tab-left': {key: {binds: ['Q'], type: ['press']}, button: {binds: ['left-sholder'], type: 'press'}, callback: menuLeft, global: true}, + 'tab-right': {key: {binds: ['E'], type: ['press']}, button: {binds: ['right-sholder'], type: 'press'}, callback: menuRight, global: true}, + 'select': {key: {binds: ['E'], type: ['press']}, callback: () => console.log('ndasd'), }, + } + + const addMoreActions = () => { + navigationRef?.addAction('yabadabado', {key: {binds: ['ARROW_UP'], type: ['press']}, callback: () => console.log('yabadadabadoo'), paused: true}) + navigationRef?.addAction('test', {key: {binds: ['W']}, callback: (scope, customArg) => {console.log(scope, customArg)}}) + navigationRef?.addAction('test2', {key: {binds: ['S']}, callback: () => testFunc(1,2)}) + } + + const updateAction = () => { + navigationRef?.updateAction('select', { + key: {binds: ['SPACEBAR'], type: ['press']}, + button: {binds: ['face-button-down']}, + callback: () => console.log('yabadadabadoo') + }); + } + return ( - tutorialRef?.exit()} />} > - - -
- - - - - -

Options

-
- - - - {(tab) => { - return - {tab} - - }} - + + + + tutorialRef?.exit()} />} > + + +
+ + + + + +

Options

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Escape Exit - - - Enter Select - - - E Defaults - + + + + {(tab) => { + return + {tab} + + }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - -
-
-
-
-
- + + + + + + + Escape Exit + + + Enter Select + + + E Defaults + + + + + +
+
+
+
+
+
+ +
); }; diff --git a/src/views/menu/util/index.ts b/src/views/menu/util/index.ts index c2ac236..5a996ac 100644 --- a/src/views/menu/util/index.ts +++ b/src/views/menu/util/index.ts @@ -1,4 +1,4 @@ -import eventBus from "@components/tools/EventBus"; +import eventBus from "@components/Utility/EventBus"; import { OPTIONS } from "../Menu"; export const getFirstOptionOfTab = (tab: string) => { From 60f61338c471b0027f59e3f10ed754345ee3929c Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Wed, 26 Nov 2025 11:44:59 +0200 Subject: [PATCH 08/29] update im version --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 40e7087..17090aa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dependencies": { "@solid-primitives/jsx-tokenizer": "^1.1.1", "@types/node": "^22.9.0", + "coherent-gameface-interaction-manager": "^2.4.4", "cors-env": "^1.0.2", "dotenv": "^17.2.0", "glob": "^11.0.0", From 8a5f22cdc413083d1ac299568886a612e5c4a6ed Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Wed, 26 Nov 2025 17:45:10 +0200 Subject: [PATCH 09/29] Make components default actions extendable --- .../content/docs/components/Basic/stepper.mdx | 31 ++++++++++++ .../docs/components/Utility/Navigation.mdx | 50 +++++++++++++++++++ src/components/Basic/Stepper/Stepper.tsx | 3 +- src/components/types/ComponentProps.d.ts | 3 +- .../Menu/Options/Gameplay/Gameplay.tsx | 2 +- src/views/menu/Menu.tsx | 2 +- 6 files changed, 87 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/components/Basic/stepper.mdx b/docs/src/content/docs/components/Basic/stepper.mdx index e38d24f..57667da 100644 --- a/docs/src/content/docs/components/Basic/stepper.mdx +++ b/docs/src/content/docs/components/Basic/stepper.mdx @@ -41,6 +41,7 @@ export default App; | `controls-position` | `'before' \| 'after'` | `''` | Determines the position of the control arrows. If not set, the selected option appears between the arrows. If set to `'before'`, the arrows appear before the selected option; if `'after'`, they appear after it. | | `loop` | `boolean` | `false` | Enables looping through options when navigating past the first or last option using the controls. | | `onChange` | `(value: string) => void` | `undefined` | Callback function triggered whenever the selected option changes, providing the selected option's value. | +| `navigation-actions` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. See [Navigation Actions](#navigation-actions) for details. | ## Ref API @@ -150,6 +151,36 @@ const App = () => { }; ``` +## Implemented Navigation Actions + +The Stepper component implements the following navigation actions by default: + +| Action Name | Behavior | +|--------------|-----------------------------------------------| +| `move-left` | Moves to the previous option in the stepper | +| `move-right` | Moves to the next option in the stepper | + +You can extend the Stepper with additional navigation actions or override the default behavior using the `navigation-actions` prop: + +```tsx +import Stepper from '@components/Basic/Stepper/Stepper'; + + console.log('Current option selected!'), + 'back': () => console.log('Going back from stepper') + }} +> + + red + green + blue + + +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrieve the selected option's value on change diff --git a/docs/src/content/docs/components/Utility/Navigation.mdx b/docs/src/content/docs/components/Utility/Navigation.mdx index eee3007..082c0b0 100644 --- a/docs/src/content/docs/components/Utility/Navigation.mdx +++ b/docs/src/content/docs/components/Utility/Navigation.mdx @@ -236,6 +236,56 @@ const Card = (props) => { The `navigationActions` directive only triggers callbacks when the component or its anchor is focused. This ensures actions are contextual and don't fire globally for all components. ::: +### Extending Component Navigation Actions + +Many preset components in the Gameface UI library (such as [Stepper](/components/basic/stepper), Dropdown, etc.) come with predefined navigation action handlers. For example, the Stepper component responds to `move-left` and `move-right` actions by default. + +You can extend or override these default behaviors using the `navigation-actions` prop available on all components that extend `ComponentProps`. This allows you to: + +- Add additional action handlers to components (e.g., adding a `select` action to a Stepper) +- Override the component's default action behavior +- Customize how components respond to navigation inputs + +**Example - Adding a select action to a Stepper:** + +```tsx +import Stepper from '@components/Basic/Stepper/Stepper'; + + console.log('Stepper item confirmed!') + }} +> + + Option 1 + Option 2 + + +``` + +**Example - Overriding default behavior:** + +```tsx + customPreviousLogic(), // Overrides default + 'select': () => handleSelection() // Extends default + }} +> + {/* ... */} + +``` + +:::caution[Overriding Component Actions] +When you provide an action that a component already implements (like `move-left` for Stepper), your handler will **override** the component's default behavior. This gives you full control but can break expected component functionality if not used carefully. + +Each component's documentation lists which navigation actions it implements by default. Always check the component's documentation before overriding actions. +::: + +:::tip +The `navigation-actions` prop uses object spreading, so user-provided actions are merged with component defaults. Actions you define will take precedence over the component's predefined actions. +::: + ### Action Configuration Reference (ActionCfg) When defining actions, you can configure the following properties: diff --git a/src/components/Basic/Stepper/Stepper.tsx b/src/components/Basic/Stepper/Stepper.tsx index adb7dae..89599e3 100644 --- a/src/components/Basic/Stepper/Stepper.tsx +++ b/src/components/Basic/Stepper/Stepper.tsx @@ -157,7 +157,8 @@ const Stepper: ParentComponent = (props) => { use:navigationActions={{ anchor: props.anchor, 'move-left': () => changeSelected('prev'), - 'move-right': () => changeSelected('next') + 'move-right': () => changeSelected('next'), + ...props['navigation-actions'] }} > diff --git a/src/components/types/ComponentProps.d.ts b/src/components/types/ComponentProps.d.ts index f366aea..ae6b982 100644 --- a/src/components/types/ComponentProps.d.ts +++ b/src/components/types/ComponentProps.d.ts @@ -32,7 +32,8 @@ export interface ComponentProps = {}> extends Comp componentClasses?: string | (() => string) ref?: unknown | ((ref: BaseComponentRef & T) => void); refObject?: T; - anchor?: HTMLElement | string + anchor?: HTMLElement | string; + 'navigation-actions'?: NavigationActionsConfig } export interface TokenComponentProps { diff --git a/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx b/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx index a319a38..a8e25e1 100644 --- a/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx +++ b/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx @@ -20,7 +20,7 @@ const Gameplay: ParentComponent = () => { return ( - + console.log('custom select implementation')}}> Easy Normal diff --git a/src/views/menu/Menu.tsx b/src/views/menu/Menu.tsx index 3885ce2..dfcfb1e 100644 --- a/src/views/menu/Menu.tsx +++ b/src/views/menu/Menu.tsx @@ -136,7 +136,7 @@ const Menu = () => { const defaultActions: ActionMap = { 'tab-left': {key: {binds: ['Q'], type: ['press']}, button: {binds: ['left-sholder'], type: 'press'}, callback: menuLeft, global: true}, 'tab-right': {key: {binds: ['E'], type: ['press']}, button: {binds: ['right-sholder'], type: 'press'}, callback: menuRight, global: true}, - 'select': {key: {binds: ['E'], type: ['press']}, callback: () => console.log('ndasd'), }, + 'select': {key: {binds: ['SPACEBAR'], type: ['press']}, callback: () => console.log('ndasd'), }, } const addMoreActions = () => { From 6988ea94d974987f71708938c205e8952e00f0bd Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Mon, 5 Jan 2026 15:18:48 +0200 Subject: [PATCH 10/29] Add navigation to components (#61) * Extend button, toggleButton and checkbox and add docs * Extend sliders to use navigation * Extend tooltip to implement actions * Extend dropdown with navigation actions * handle dropdown nav edge cases * Add nav actions to inputs and add reusable input wrapper component * Refactor Navigation pause/resume to avoid deinit cycles * Extend Accordion to use navigation actions * Fix anchor and action type inconsistencies across components * Fix IM types after ts migration * resolve pr comments --- .../video/accordion/header-navigation.webm | Bin 0 -> 35447 bytes .../docs/components/Basic/accordion.mdx | 169 ++++++++++++++++++ .../content/docs/components/Basic/button.mdx | 2 + .../docs/components/Basic/checkbox.mdx | 28 +++ .../docs/components/Basic/dropdown.mdx | 36 ++++ .../docs/components/Basic/number-input.mdx | 35 ++++ .../docs/components/Basic/password-input.mdx | 35 ++++ .../content/docs/components/Basic/slider.mdx | 32 +++- .../content/docs/components/Basic/stepper.mdx | 7 +- .../docs/components/Basic/text-input.mdx | 26 +++ .../docs/components/Basic/text-slider.mdx | 29 +++ .../docs/components/Basic/toggle-button.mdx | 28 +++ .../docs/components/Feedback/tooltip.mdx | 32 ++++ .../docs/components/Utility/Navigation.mdx | 13 +- .../Basic/Accordion/Accordion.module.scss | 4 + src/components/Basic/Accordion/Accordion.tsx | 8 +- .../Basic/Accordion/AccordionHeading.tsx | 21 ++- src/components/Basic/Button/Button.tsx | 8 +- src/components/Basic/Checkbox/Checkbox.tsx | 6 +- .../Basic/Dropdown/Dropdown.module.scss | 11 +- src/components/Basic/Dropdown/Dropdown.tsx | 75 ++++++-- .../Basic/Dropdown/DropdownOption.tsx | 19 +- .../Basic/Dropdown/DropdownTrigger.tsx | 2 +- .../Input/InputBase/InputBase.module.scss | 3 +- .../Basic/Input/InputBase/InputBase.tsx | 2 +- .../Basic/Input/InputBase/InputWrapper.tsx | 68 +++++++ .../Basic/Input/NumberInput/NumberInput.tsx | 83 ++++----- .../Input/PasswordInput/PasswordInput.tsx | 62 ++----- .../Basic/Input/TextInput/TextInput.tsx | 55 ++---- src/components/Basic/Slider/Slider.tsx | 11 +- src/components/Basic/Stepper/Stepper.tsx | 13 +- .../Basic/TextSlider/TextSlider.tsx | 26 ++- .../Basic/ToggleButton/ToggleButton.tsx | 6 +- src/components/Feedback/Tooltip/tooltip.tsx | 14 +- .../Utility/Navigation/Navigation.tsx | 23 ++- .../Utility/Navigation/NavigationArea.tsx | 14 +- .../actionMethods/useActionMethods.ts | 3 +- .../areaMethods/areaMethods.types.ts | 14 +- .../Navigation/areaMethods/useAreaMethods.ts | 48 ++--- .../keybindings/keybindings.types.ts | 106 ----------- src/components/Utility/Navigation/types.ts | 8 +- src/components/types/ComponentProps.d.ts | 9 +- .../utils/mergeNavigationActions.ts | 12 ++ .../Menu/CustomDropdown/_menu-theme.scss | 12 +- .../Menu/Options/Gameplay/Gameplay.tsx | 2 +- 45 files changed, 840 insertions(+), 380 deletions(-) create mode 100644 docs/public/video/accordion/header-navigation.webm create mode 100644 src/components/Basic/Input/InputBase/InputWrapper.tsx create mode 100644 src/components/utils/mergeNavigationActions.ts diff --git a/docs/public/video/accordion/header-navigation.webm b/docs/public/video/accordion/header-navigation.webm new file mode 100644 index 0000000000000000000000000000000000000000..2bf623d16d94dff1afe3d1cd025916998864c451 GIT binary patch literal 35447 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 literal 0 HcmV?d00001 diff --git a/docs/src/content/docs/components/Basic/accordion.mdx b/docs/src/content/docs/components/Basic/accordion.mdx index d51f8a5..7f4de69 100644 --- a/docs/src/content/docs/components/Basic/accordion.mdx +++ b/docs/src/content/docs/components/Basic/accordion.mdx @@ -124,6 +124,8 @@ It displays the panel's title or label and handles user interaction for expandin | `style` | `JSX.CSSProperties` | `{}` | Inline styles to apply to the heading element. | | `class` | `string` | `""` | Additional CSS classes for the heading element. | | `children` | `JSX.Element` | `""` | Content of the heading, used to render text, HTML, or JSX elements within the heading. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the heading's default navigation action handlers. 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 heading's actions will execute. Can be a CSS selector or HTMLElement. | ### `Accordion.Icon` @@ -185,6 +187,37 @@ It displays the main content associated with the panel. | `class` | `string` | `""` | Additional CSS classes for the body element. | | `children` | `JSX.Element` | `""` | Content of the body, used to render text, HTML, or JSX elements within the body. | +## Implemented Navigation Actions + +The `Accordion.Heading` component slot implements the following navigation action by default: + +| Action Name | Behavior | +|--------------|-----------------------------------------------------------------------------------------------| +| `select` | Toggles the accordion panel (expands if collapsed, collapses if expanded) | + +You can extend the `Accordion.Heading` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import Accordion from '@components/Basic/Accordion/Accordion'; + + + + console.log('Custom select behavior!'), + 'info': () => console.log('Show panel info') + }} + > + Heading 1 + + Accordion content + + +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrieve the modified panel's title on change @@ -262,4 +295,140 @@ const App = () => { }; export default App; +``` + +### Making accordion headings navigable + +The `Accordion.Heading` needs to be focused in order to respond to the `select` action. + +To enable gamepad/keyboard navigation for accordion headings, add them to a navigation area by assigning a CSS class and registering them with `Navigation.Area` or `navigationRef.registerArea()`. + +#### Steps + +1. Wrap your `Accordion` inside a `Navigation` component +2. Assign a CSS class to each `Accordion.Heading` you want to be navigable +3. Register the headings as a navigation area using either: + - `navigationRef.registerArea()` with the class selector + - Or wrap the accordion in `Navigation.Area` with the appropriate selector + +```tsx +import Navigation, { NavigationRef } from '@components/Utility/Navigation/Navigation'; +import Accordion from '@components/Basic/Accordion/Accordion'; + +const App = () => { + let navigationRef: NavigationRef | undefined; + + // Register accordion headings as a navigation area + onMount(() => { + navigationRef?.registerArea('accordion-headers', ['.accordion-heading']); + }); + + return ( + + + + + Settings + + Settings content + + + + Audio + + Audio content + + + + ); +}; + +export default App; +``` + +### Navigating accordion body content + + + +For more complex accordion implementations where the body contains navigable elements (like form inputs, buttons, or lists), you can create a `Navigation.Area` inside the `Accordion.Body` and automatically focus it when the panel expands. + +#### Steps + +1. Wrap the elements you wish to be navigable inside `Accordion.Body` with `Navigation.Area` +2. Use the `onChange` callback to detect when the panel expands +3. Call `navigationRef.focusFirst()` or `navigationRef.switchArea()` to focus the body's navigation area +4. Optionally set up a `back` action to return focus to the accordion headings + +```tsx +import Navigation, { NavigationRef } from '@components/Utility/Navigation/Navigation'; +import Accordion from '@components/Basic/Accordion/Accordion'; + +const App = () => { + let navigationRef: NavigationRef | undefined; + + // Handle accordion panel changes + const handleAccordionChange = (title: string) => { + if (title === 'settings-panel') { + // Focus the navigation area inside the expanded panel + navigationRef?.focusFirst('settings-area'); + } + }; + + onMount(() => { + // Register accordion headings as an area and focus it. + navigationRef?.registerArea('accordion-headers', ['.accordion-heading'], true); + + // Configure back action to return to accordion headers + navigationRef?.updateAction('back', { + key: { binds: ['ESC'], type: ['press'] }, + button: { binds: ['face-button-right'] }, + callback: (scope) => { + if (scope === 'settings-area') { + navigationRef?.focusFirst('accordion-headers'); + } + } + }); + }); + + return ( + + + + + Settings + + + + + + + + + + + + Audio + + Audio settings content + + + + ); +}; + +export default App; +``` + +CSS for `.menu-item`: + +```css +.menu-item { + padding: 1vmax; + border-bottom: 1px solid #232323; + background-color: #cecece; +} + +.menu-item:focus { + background-color: #b1b1b1; +} ``` \ No newline at end of file diff --git a/docs/src/content/docs/components/Basic/button.mdx b/docs/src/content/docs/components/Basic/button.mdx index a66cc89..cf00460 100644 --- a/docs/src/content/docs/components/Basic/button.mdx +++ b/docs/src/content/docs/components/Basic/button.mdx @@ -37,6 +37,8 @@ export default App; | `disabled` | `boolean` | `false` | Specify if the button is disabled | | `size` | `'large' \| 'middle' \| 'small'` | `''` | Specify the size of the button. If an empty string is passed, the button won't have any size. In that case, please specify the size through the `class` or `style` properties. | | `textFit` | `boolean` | `true` | Specify if the text inside the button should be fitted. By default, this option is **enabled**. | +| `onAction` | `Record void>` | `undefined` | Allows you to add custom navigation action handlers to the button. See the [Navigation component documentation](/components/utility/navigation#extending-component-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. | ## Guide diff --git a/docs/src/content/docs/components/Basic/checkbox.mdx b/docs/src/content/docs/components/Basic/checkbox.mdx index 3d97d53..890d128 100644 --- a/docs/src/content/docs/components/Basic/checkbox.mdx +++ b/docs/src/content/docs/components/Basic/checkbox.mdx @@ -38,6 +38,8 @@ export default App; | `value` | `any` | `''` | The value associated with the checkbox component. | | `checked` | `boolean` | `false` | Specify if the checkbox is checked initially. | | `onChange` | `(checked: boolean) => void` | `undefined` | A function that is called every time the checkbox is toggled. It can be used to retrieve whether the checkbox is checked. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 checkbox's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -176,6 +178,32 @@ const App = () => { export default App; ``` +## Implemented Navigation Actions + +The `Checkbox` component implements the following navigation actions by default: + +| Action Name | Behavior | +|-------------|-----------------------------------| +| `select` | Toggles the checkbox on/off | + +You can extend the Checkbox with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import Checkbox from '@components/Basic/Checkbox/Checkbox'; + + console.log('Going back from checkbox'), + 'select': () => console.log('Custom toggle logic') // Overrides default + }} +> + Enable V-Sync + +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrive the Checkbox Value diff --git a/docs/src/content/docs/components/Basic/dropdown.mdx b/docs/src/content/docs/components/Basic/dropdown.mdx index ed93148..b8fc78d 100644 --- a/docs/src/content/docs/components/Basic/dropdown.mdx +++ b/docs/src/content/docs/components/Basic/dropdown.mdx @@ -39,6 +39,8 @@ export default App; | `disabled` | `boolean` | `false` | Disables the dropdown when set to `true`. | | `class-disabled` | `string` | `""` | Additional CSS classes to apply when the dropdown is disabled. | | `onChange` | `(value: string) => void` | `undefined` | Callback function triggered whenever the selected option changes, providing the selected option's value. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 dropdown's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -268,6 +270,40 @@ To hide the `Dropdown.Handle`, set its `display` style to `none`: ::: +## Implemented Navigation Actions + +The `Dropdown` component implements the following navigation actions by default: + +| Action Name | Behavior | +|--------------|-----------------------------------------------------------------------------------------------| +| `select` | Opens the dropdown when closed. When open, collapses the dropdown (options handle selection) | +| `back` | Closes the dropdown and returns focus to the trigger or anchor element | + +You can extend the `Dropdown` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import Dropdown from '@components/Basic/Dropdown/Dropdown'; + + console.log('Custom select behavior!'), + 'back': () => console.log('Custom back behavior!') + }} +> + + red + green + blue + + +``` + +```caution +Every `Dropdown.Option` component is listening for the `select` action to handle option selection. This action cannot be overriden. +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrieve the selected option's value on change diff --git a/docs/src/content/docs/components/Basic/number-input.mdx b/docs/src/content/docs/components/Basic/number-input.mdx index c52a5f6..cf57ae6 100644 --- a/docs/src/content/docs/components/Basic/number-input.mdx +++ b/docs/src/content/docs/components/Basic/number-input.mdx @@ -40,6 +40,8 @@ export default App; | `class-disabled` | `string` | `undefined` | Optional class to apply when the input is disabled. | | `readonly` | `boolean` | `false` | Specifies if the input is only able to be read. If set to true, the input will not accept values. | | `onChange` | `(value: string \| number) => void` | `undefined` | A function that is called every time the input's value has changed. It can be used to retrieve the up to date value of the input. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 input's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -177,6 +179,39 @@ const App = () => { export default App; ``` +## Implemented Navigation Actions + +The `NumberInput` component implements the following navigation actions by default: + +| Action Name | Behavior | +|-------------|---------------------------------------------------------| +| `select` | Focuses the input element, making it ready for typing | +| `back` | Blurs the input element, exiting typing mode | +| `move-up` | Increases the value of the input by the `step` prop's value | +| `move-down` | Increases the value of the input by the `step` prop's value | + +You can extend the `NumberInput` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import NumberInput, { NumberInputRef } from '@components/Basic/NumberInput/NumberInput'; + +const App = () => { + let inputRef!: NumberInputRef; + + return ( + console.log('Custom select action'), + 'back': () => console.log('Custom back action'), + }} + /> + ); +}; +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Increase or decrease value programmatically diff --git a/docs/src/content/docs/components/Basic/password-input.mdx b/docs/src/content/docs/components/Basic/password-input.mdx index 76501b5..b117a9e 100644 --- a/docs/src/content/docs/components/Basic/password-input.mdx +++ b/docs/src/content/docs/components/Basic/password-input.mdx @@ -38,6 +38,8 @@ export default App; | `max-symbols` | `number` | `undefined` | Maximum number of symbols the input can accept. | | `class-disabled` | `string` | `undefined` | Optional class to apply when the input is disabled. | | `onChange` | `(value: string) => void` | `undefined` | A function that is called every time the input's value has changed. It can be used to retrieve the up to date value of the input. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 input's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -186,6 +188,39 @@ const App = () => { export default App; ``` +## Implemented Navigation Actions + +The `PasswordInput` component implements the following navigation actions by default: + +| Action Name | Behavior | +|-------------|---------------------------------------------------------| +| `select` | Focuses the input element, making it ready for typing | +| `back` | Blurs the input element, exiting typing mode | + +You can extend the `PasswordInput` with additional navigation actions or override the default behavior using the `onAction` prop. +For example, you can bind the visibility toggle to a custom action like: + +```tsx +import PasswordInput, { PasswordInputRef } from '@components/Basic/PasswordInput/PasswordInput'; + +const App = () => { + let inputRef!: PasswordInputRef; + + return ( + { + inputRef.visible() ? inputRef.hide() : inputRef.show(); + } + }} + /> + ); +}; +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Toggle input visibility programmatically diff --git a/docs/src/content/docs/components/Basic/slider.mdx b/docs/src/content/docs/components/Basic/slider.mdx index 40575b8..558a7bb 100644 --- a/docs/src/content/docs/components/Basic/slider.mdx +++ b/docs/src/content/docs/components/Basic/slider.mdx @@ -36,6 +36,8 @@ export default App; | `max` | `number` | `undefined` | The maximum value that the slider can select. | | `step` | `number` | `undefined` | The amount by which the slider value changes when the handle is moved. Determines the granularity of the slider. | | `onChange` | `(value: number) => void` | `undefined` | A function that is called every time the slider's value has changed. It can be used to retrieve the up to date value of the slider. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 slider's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -216,9 +218,37 @@ export default App; ``` :::caution -The `Slider.Grid` slot will only be rendered if you include it as a child of the `Slider` component. +The `Slider.Grid` slot will only be rendered if you include it as a child of the `Slider` component. ::: +## Implemented Navigation Actions + +The `Slider` component implements the following navigation actions by default: + +| Action Name | Behavior | +|--------------|-------------------------------------------------------------| +| `move-left` | Decreases the slider value by the `step` amount | +| `move-right` | Increases the slider value by the `step` amount | + +You can extend the `Slider` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import Slider from '@components/Basic/Slider/Slider'; + + console.log('Slider confirmed!'), + 'back': () => console.log('Going back') + }} +/> +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrieve and use the slider value diff --git a/docs/src/content/docs/components/Basic/stepper.mdx b/docs/src/content/docs/components/Basic/stepper.mdx index 57667da..812cf02 100644 --- a/docs/src/content/docs/components/Basic/stepper.mdx +++ b/docs/src/content/docs/components/Basic/stepper.mdx @@ -41,7 +41,8 @@ export default App; | `controls-position` | `'before' \| 'after'` | `''` | Determines the position of the control arrows. If not set, the selected option appears between the arrows. If set to `'before'`, the arrows appear before the selected option; if `'after'`, they appear after it. | | `loop` | `boolean` | `false` | Enables looping through options when navigating past the first or last option using the controls. | | `onChange` | `(value: string) => void` | `undefined` | Callback function triggered whenever the selected option changes, providing the selected option's value. | -| `navigation-actions` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. See [Navigation Actions](#navigation-actions) for details. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 stepper's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -160,13 +161,13 @@ The Stepper component implements the following navigation actions by default: | `move-left` | Moves to the previous option in the stepper | | `move-right` | Moves to the next option in the stepper | -You can extend the Stepper with additional navigation actions or override the default behavior using the `navigation-actions` prop: +You can extend the Stepper with additional navigation actions or override the default behavior using the `onAction` prop: ```tsx import Stepper from '@components/Basic/Stepper/Stepper'; console.log('Current option selected!'), 'back': () => console.log('Going back from stepper') }} diff --git a/docs/src/content/docs/components/Basic/text-input.mdx b/docs/src/content/docs/components/Basic/text-input.mdx index a3b3d77..5328dca 100644 --- a/docs/src/content/docs/components/Basic/text-input.mdx +++ b/docs/src/content/docs/components/Basic/text-input.mdx @@ -39,6 +39,8 @@ export default App; | `max-symbols` | `number` | `undefined` | Maximum number of symbols the input can accept. | | `class-disabled` | `string` | `undefined` | Optional class to apply when the input is disabled. | | `onChange` | `(value: string) => void` | `undefined` | A function that is called every time the input's value has changed. It can be used to retrieve the up to date value of the input. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 input's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -178,6 +180,30 @@ const App = () => { export default App; ``` +## Implemented Navigation Actions + +The `TextInput` component implements the following navigation actions by default: + +| Action Name | Behavior | +|-------------|---------------------------------------------------------| +| `select` | Focuses the input element, making it ready for typing | +| `back` | Blurs the input element, exiting typing mode | + +You can extend the `TextInput` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import TextInput from '@components/Basic/TextInput/TextInput'; + + console.log('Custom select action!'), + 'move-up': () => console.log('Move up pressed') + }} +/> +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrieve the input value diff --git a/docs/src/content/docs/components/Basic/text-slider.mdx b/docs/src/content/docs/components/Basic/text-slider.mdx index f0d0849..993b9c6 100644 --- a/docs/src/content/docs/components/Basic/text-slider.mdx +++ b/docs/src/content/docs/components/Basic/text-slider.mdx @@ -31,6 +31,8 @@ export default App; | `value` | `string` | `undefined` | The current value of the slider. Use this to control or set the slider's position. | | `values` | `string[]` | `undefined` | An array of options that can be selected by dragging the slider's handle. | | `onChange` | `(value: string) => void` | `undefined` | A callback function triggered whenever the slider's value changes. It provides the updated value of the slider. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 slider's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -49,6 +51,7 @@ To interact with the `TextSlider` programmatically, you can use the `TextSliderR | Method | Parameters | Return Value | Description | |----------------|-------------------|--------------|--------------------------------------------------| | `changeValue` | `newValue: string`| `void` | Programmatically sets a new value for the text slider. | +| `stepValue` | `direction: 1 \| -1` | `string` | Steps to the next (`1`) or previous (`-1`) value in the values array. | ## Slots @@ -192,6 +195,32 @@ const App = () => { export default App; ``` +## Implemented Navigation Actions + +The `TextSlider` component implements the following navigation actions by default: + +| Action Name | Behavior | +|--------------|-------------------------------------------------------------| +| `move-left` | Moves to the previous value in the values array | +| `move-right` | Moves to the next value in the values array | + +You can extend the `TextSlider` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import TextSlider from '@components/Basic/TextSlider/TextSlider'; + + console.log('Value selected!'), + 'back': () => console.log('Going back') + }} +/> +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrieve and use the text slider value diff --git a/docs/src/content/docs/components/Basic/toggle-button.mdx b/docs/src/content/docs/components/Basic/toggle-button.mdx index 4e7dada..8a49a58 100644 --- a/docs/src/content/docs/components/Basic/toggle-button.mdx +++ b/docs/src/content/docs/components/Basic/toggle-button.mdx @@ -37,6 +37,8 @@ export default App; | `class-disabled`| `string` | `""` | Additional CSS classes applied when the toggle button is disabled. | | `checked` | `boolean` | `false` | Specifies the initial checked state of the toggle button. | | `onChange` | `(checked: boolean) => void`| `undefined` | Callback function triggered whenever the toggle button is toggled, providing the current checked state. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 toggle button's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -219,6 +221,32 @@ const App = () => { export default App; ``` +## Implemented Navigation Actions + +The `ToggleButton` component implements the following navigation actions by default: + +| Action Name | Behavior | +|-------------|-----------------------------------| +| `select` | Toggles the button on/off | + +You can extend the `ToggleButton` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import ToggleButton from '@components/Basic/ToggleButton/ToggleButton'; + + console.log('Going back from toggle button'), + 'select': () => console.log('Custom toggle logic') // Overrides default + }} +> + Off + On + +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + ## Guide ### Retrieve the ToggleButton value diff --git a/docs/src/content/docs/components/Feedback/tooltip.mdx b/docs/src/content/docs/components/Feedback/tooltip.mdx index 44c93cb..40633ce 100644 --- a/docs/src/content/docs/components/Feedback/tooltip.mdx +++ b/docs/src/content/docs/components/Feedback/tooltip.mdx @@ -49,6 +49,8 @@ Creates a new tooltip component with customizable options. | `content` | `JSX.Element` | `(props: { message: string }) => JSX.Element`| The content of the tooltip message. Can be a JSX element. If not specified, the `message` prop will be used to display a simple text message. | | `position` | `'top' \| 'bottom' \| 'left' \| 'right' \| 'auto'`| `bottom` | Defines the position of the tooltip relative to the target element. If set to `auto`, the tooltip will automatically choose the best placement based on available space. | | `action` | `'hover' \| 'click' \| 'focus'` | `hover` | Determines the user interaction that triggers the tooltip.
`hover`: shows on mouse hover and hides on mouse leave.
`click`: toggles visibility when the target is clicked.
`focus`: shows when the target gains focus (e.g., via keyboard) and hides when it loses focus. | +| `onAction` | `Record void>` | `undefined` | Extends or overrides the component's default navigation action handlers. 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 tooltip's actions will execute. Can be a CSS selector or HTMLElement. | ## Ref API @@ -67,6 +69,36 @@ To interact with the `Tooltip` programmatically, you can use the `TooltipRef` in | `show` | none | `void` | Shows the tooltip if it's hidden. | | `hide` | none | `void` | Hides the tooltip if it's visible. | +## Implemented Navigation Actions + +The `Tooltip` component implements the following navigation action by default: + +| Action Name | Behavior | +|-------------|---------------------------------------------| +| `back` | Dismisses the visible tooltip | + +This allows users to dismiss tooltips using keyboard or gamepad navigation when the tooltip's target element is focused. + +```tsx +import createTooltip from '@components/Feedback/Tooltip/tooltip'; + +const App = () => { + // Tooltip with default back action - dismisses on back button press + const Tooltip = createTooltip({ action: 'focus' }); + + return ( + +
Focus me and press ESC
+
+ ); +}; +``` + +:::caution[Navigation Actions Requirements] +For navigation actions to work, the tooltip must use `action: 'focus'` and the wrapped element must be focusable (set `tabindex={0}` or higher on the wrapped element). +::: + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). ## Guide diff --git a/docs/src/content/docs/components/Utility/Navigation.mdx b/docs/src/content/docs/components/Utility/Navigation.mdx index 082c0b0..abc2062 100644 --- a/docs/src/content/docs/components/Utility/Navigation.mdx +++ b/docs/src/content/docs/components/Utility/Navigation.mdx @@ -240,7 +240,7 @@ The `navigationActions` directive only triggers callbacks when the component or Many preset components in the Gameface UI library (such as [Stepper](/components/basic/stepper), Dropdown, etc.) come with predefined navigation action handlers. For example, the Stepper component responds to `move-left` and `move-right` actions by default. -You can extend or override these default behaviors using the `navigation-actions` prop available on all components that extend `ComponentProps`. This allows you to: +You can extend or override these default behaviors using the `onAction` prop available on all components that extend `ComponentProps`. This allows you to: - Add additional action handlers to components (e.g., adding a `select` action to a Stepper) - Override the component's default action behavior @@ -252,7 +252,7 @@ You can extend or override these default behaviors using the `navigation-actions import Stepper from '@components/Basic/Stepper/Stepper'; console.log('Stepper item confirmed!') }} > @@ -267,7 +267,7 @@ import Stepper from '@components/Basic/Stepper/Stepper'; ```tsx customPreviousLogic(), // Overrides default 'select': () => handleSelection() // Extends default }} @@ -283,7 +283,7 @@ Each component's documentation lists which navigation actions it implements by d ::: :::tip -The `navigation-actions` prop uses object spreading, so user-provided actions are merged with component defaults. Actions you define will take precedence over the component's predefined actions. +The `onAction` prop uses object spreading, so user-provided actions are merged with component defaults. Actions you define will take precedence over the component's predefined actions. ::: ### Action Configuration Reference (ActionCfg) @@ -342,8 +342,8 @@ The `Navigation` component exposes a comprehensive API through the `useNavigatio | `clearFocus` | None | `void` | Clears the current focus from all navigation areas. | | `changeNavigationKeys` | `keys: { up?: string, down?: string, left?: string, right?: string }, clearCurrent?: boolean` | `void` | Changes the navigation keys for spatial navigation. If `clearCurrent` is true, clears current active keys before setting new ones. | | `resetNavigationKeys` | None | `void` | Resets navigation keys to their default values. | -| `pauseNavigation` | None | `void` | Pauses spatial navigation by deinitializing it. Saves the currently focused element for restoration on resume. | -| `resumeNavigation` | None | `void` | Resumes spatial navigation by re-enabling it. Attempts to restore focus to the previously focused element, or focuses the first element of the current `scope` if restoration fails. | +| `pauseNavigation` | None | `void` | Pauses navigation, preventing spatial navigation actions from executing. | +| `resumeNavigation` | None | `void` | Resumes navigation, allowing spatial navigation actions to execute again. | ## Slots @@ -359,7 +359,6 @@ When a `Navigation.Area` mounts: - It registers itself with the spatial navigation system - If its `name` matches the parent Navigation's `scope` prop or if `focused={true}`, it auto-focuses - It automatically handles cleanup on unmount -- It re-registers when navigation is resumed after being paused #### Navigation.Area Props diff --git a/src/components/Basic/Accordion/Accordion.module.scss b/src/components/Basic/Accordion/Accordion.module.scss index 03a8ede..1c384c6 100644 --- a/src/components/Basic/Accordion/Accordion.module.scss +++ b/src/components/Basic/Accordion/Accordion.module.scss @@ -33,6 +33,10 @@ &-content { flex: 1; } + + &:focus { + background-color: #dcdcdc + } } diff --git a/src/components/Basic/Accordion/Accordion.tsx b/src/components/Basic/Accordion/Accordion.tsx index cb5276a..3e8c943 100644 --- a/src/components/Basic/Accordion/Accordion.tsx +++ b/src/components/Basic/Accordion/Accordion.tsx @@ -1,11 +1,11 @@ import { ComponentProps } from "@components/types/ComponentProps"; -import { Accessor, createContext, createMemo, createSignal, createUniqueId, For, onMount, ParentComponent, Setter } from "solid-js"; -import styles from './Accordion.module.scss'; +import { Accessor, createContext, createMemo, createSignal, createUniqueId, For, onMount, ParentComponent } from "solid-js"; import useBaseComponent from "@components/BaseComponent/BaseComponent"; import { AccordionPanel, Panel, PanelTokenProps } from "./AccordionPanel"; import { Heading, Icon } from "./AccordionHeading"; import { Body } from "./AccordionBody"; import { useTokens } from "@components/utils/tokenComponents"; +import styles from './Accordion.module.scss'; export interface AccordionRef { element: HTMLDivElement, @@ -20,7 +20,7 @@ export interface PanelData { id: string } -interface AccordionProps extends ComponentProps { +interface AccordionProps extends Omit { multiple?: boolean; disabled?: boolean; 'class-disabled'?: string; @@ -167,7 +167,7 @@ const Accordion: ParentComponent = (props) => { class={className()} style={inlineStyles()} use:forwardEvents={props} - use:forwardAttrs={props} > + use:forwardAttrs={props}> {(data) => } diff --git a/src/components/Basic/Accordion/AccordionHeading.tsx b/src/components/Basic/Accordion/AccordionHeading.tsx index 5083e86..febd3ec 100644 --- a/src/components/Basic/Accordion/AccordionHeading.tsx +++ b/src/components/Basic/Accordion/AccordionHeading.tsx @@ -4,22 +4,35 @@ import AccordionIcon from './AccordionIcon.svg?component-solid' import styles from './Accordion.module.scss'; import { CommonAccordionSlotProps, PanelChildrenComponentProps } from "./AccordionPanel"; import { AccordionContext } from "./Accordion"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; +import useBaseComponent from "@components/BaseComponent/BaseComponent"; +import { ComponentNavigationActions } from "@components/types/ComponentProps"; -export const Heading = createTokenComponent(); +interface AccordionHeadingProps extends CommonAccordionSlotProps { + onAction?: ComponentNavigationActions, + anchor?: string | HTMLElement, +} + +export const Heading = createTokenComponent(); export const Icon = createTokenComponent(); -export const AccordionHeading: ParentComponent<{ id: string } & PanelChildrenComponentProps> = (props) => { +export const AccordionHeading: ParentComponent<{ id: string, onAction?: Record void> } & PanelChildrenComponentProps> = (props) => { const HeadingToken = useToken(Heading, props.parentChildren); const IconToken = useToken(Icon, HeadingToken?.()?.children) const accordion = useContext(AccordionContext) + const { navigationActions } = useBaseComponent({} as any); + return (
accordion?.toggle(props.id)}> + onclick={() => accordion?.toggle(props.id)} + use:navigationActions={mergeNavigationActions(HeadingToken() as any, { + 'select': () => accordion?.toggle(props.id) + })}>
{HeadingToken()?.children}
-
{IconToken?.()?.children || } diff --git a/src/components/Basic/Button/Button.tsx b/src/components/Basic/Button/Button.tsx index 60aa750..310d025 100644 --- a/src/components/Basic/Button/Button.tsx +++ b/src/components/Basic/Button/Button.tsx @@ -26,14 +26,18 @@ const Button: ParentComponent = (props) => { const mergedProps = mergeProps({ textFit: true }, props); props.componentClasses = createMemo(() => getButtonClasses(mergedProps).join(' ')) - const {className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const {className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); return } diff --git a/src/components/Basic/Checkbox/Checkbox.tsx b/src/components/Basic/Checkbox/Checkbox.tsx index f973787..1a6f4c6 100644 --- a/src/components/Basic/Checkbox/Checkbox.tsx +++ b/src/components/Basic/Checkbox/Checkbox.tsx @@ -5,6 +5,7 @@ import useBaseComponent from "@components/BaseComponent/BaseComponent"; import { Control, CheckboxControl } from "./CheckboxControl"; import { Indicator } from "./CheckboxIndicator"; import { createTokenComponent, useToken } from '@components/utils/tokenComponents'; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; const Label = createTokenComponent<{ before?: boolean }>(); @@ -51,9 +52,9 @@ const Checkbox: ParentComponent = (props) => { props.componentClasses = () => checkboxClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); - const toggle = (e?: MouseEvent) => { + const toggle = () => { if (props.disabled) return; setChecked(prev => !prev); @@ -83,6 +84,7 @@ const Checkbox: ParentComponent = (props) => { style={inlineStyles()} use:forwardEvents={props} use:forwardAttrs={props} + use:navigationActions={mergeNavigationActions(props, { 'select': toggle })} onclick={toggle}> diff --git a/src/components/Basic/Dropdown/Dropdown.module.scss b/src/components/Basic/Dropdown/Dropdown.module.scss index 7930fb2..dfcd4ff 100644 --- a/src/components/Basic/Dropdown/Dropdown.module.scss +++ b/src/components/Basic/Dropdown/Dropdown.module.scss @@ -12,14 +12,19 @@ display: flex; border-radius: 0.3vmax; - &:hover { + &:hover, + &:focus { background-color: $primaryColor; color: $textColor; } &-selected { - background-color: $primaryColor; - color: $textColor; + &, + &:hover, + &:focus { + background-color: $primaryColor; + color: $textColor; + } } &-disabled { diff --git a/src/components/Basic/Dropdown/Dropdown.tsx b/src/components/Basic/Dropdown/Dropdown.tsx index f608001..6f11f16 100644 --- a/src/components/Basic/Dropdown/Dropdown.tsx +++ b/src/components/Basic/Dropdown/Dropdown.tsx @@ -1,5 +1,4 @@ -import { Accessor, createContext, createEffect, createMemo, createSignal, DEV, JSX, onMount, ParentComponent } from 'solid-js'; -import style from './Dropdown.module.scss'; +import { Accessor, createContext, createMemo, createSignal, createUniqueId, DEV, JSX, onMount, ParentComponent } from 'solid-js'; import { DropdownOptions, Handle, Options, Track } from './DropdownOptions'; import { Option } from './DropdownOption'; import { DropdownTrigger, Icon, Placeholder, Trigger } from './DropdownTrigger'; @@ -7,7 +6,9 @@ import { BaseComponentRef, ComponentProps } from '@components/types/ComponentPro import useBaseComponent from '@components/BaseComponent/BaseComponent'; import { waitForFrames } from '@components/utils/waitForFrames'; import getScrollableParent from '@components/utils/getScrollableParent'; - +import mergeNavigationActions from '@components/utils/mergeNavigationActions'; +import { useNavigation } from '@components/Utility/Navigation/Navigation'; +import style from './Dropdown.module.scss'; export interface CommonDropdownSlotProps { style?: JSX.CSSProperties, class?: string, @@ -25,9 +26,10 @@ interface DropdownContextValue { selectOption: (value: string) => void open: Accessor; toggle: (isOpened: boolean) => void; - registerOption: (value: string, label: any, selected?: boolean) => void + registerOption: (value: string, label: string | JSX.Element, element: HTMLElement, selected?: boolean) => void unregisterOption: (value: string) => void, - options: Map, + handleNavigationClose: () => void, + options: Map, isInverted: Accessor } @@ -43,9 +45,15 @@ const Dropdown: ParentComponent = (props) => { const [firstRender, setFirstRender] = createSignal(true); const [open, setOpen] = createSignal(false); const [isInverted, setIsInverted] = createSignal(false); - const options = new Map(); - const registerOption = (value: string, label: any, selected?: boolean) => { - options.set(value, label); + + const [anchorEl, setAnchorEl] = createSignal(null); + const nav = useNavigation(); + const areaID = nav && `dropdown-area-${createUniqueId()}`; + + const options = new Map(); + + const registerOption = (value: string, label: string | JSX.Element, element: HTMLElement, selected?: boolean) => { + options.set(value, {label, element}); if (selected) selectOption(value); }; const unregisterOption = (value: string) => options.delete(value); @@ -79,12 +87,14 @@ const Dropdown: ParentComponent = (props) => { if (isOpened) { document.addEventListener('click', closeDropdown); + initOptionsArea(); setOpen(true); return; } setOpen(false); document.removeEventListener('click', closeDropdown); + deinitOptionsArea(); } const closeDropdown = (e: MouseEvent) => { @@ -95,7 +105,7 @@ const Dropdown: ParentComponent = (props) => { }; props.componentClasses = () => dropdownClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); function handlePosition() { const clipParent = getScrollableParent(element); @@ -106,17 +116,49 @@ const Dropdown: ParentComponent = (props) => { const allowedHeight = clipRect.top + clipRect.height; const totalHeight = dropdownRect.top + optionsEl.offsetHeight; if (totalHeight > allowedHeight) setIsInverted(true); - } + } + + const initOptionsArea = () => { + if (!nav || !areaID ) return; + setTimeout(() => nav.registerArea(areaID, [...options.values()].map(o => o.element) , true)) + } + + const deinitOptionsArea = () => { + if (!nav || !areaID) return; + nav.unregisterArea(areaID); + } + + const handleNavigationOpen = () => { + if (!open()) toggle(true); + } + + const handleNavigationClose = () => { + if (!open()) return; + + toggle(false); + + // focus back + const anchor = anchorEl(); + anchor ? (anchor as HTMLElement).focus() : element.focus(); + } onMount(() => { - waitForFrames(handlePosition); + waitForFrames(() => { + handlePosition() + if (props.anchor) { + const el = typeof props.anchor === 'string' + ? document.querySelector(props.anchor) + : props.anchor; + setAnchorEl(el as HTMLElement); + } + }); if (!props.ref || !element) return; + (props.ref as unknown as (ref: any) => void)({ selected, selectOption, element, }); - }); const DropdownContextValue = { @@ -125,11 +167,17 @@ const Dropdown: ParentComponent = (props) => { open, toggle, registerOption, - unregisterOption, + unregisterOption, + handleNavigationClose, options, isInverted } + const defaultActions = { + 'select': handleNavigationOpen, + 'back': handleNavigationClose, + } + return (
= (props) => { style={inlineStyles()} use:forwardEvents={props} use:forwardAttrs={props} + use:navigationActions={mergeNavigationActions(props, defaultActions)} > diff --git a/src/components/Basic/Dropdown/DropdownOption.tsx b/src/components/Basic/Dropdown/DropdownOption.tsx index a66a87c..26da552 100644 --- a/src/components/Basic/Dropdown/DropdownOption.tsx +++ b/src/components/Basic/Dropdown/DropdownOption.tsx @@ -1,7 +1,8 @@ import { ParentComponent, useContext, ParentProps, onCleanup, onMount, Show } from 'solid-js'; import { CommonDropdownSlotProps, DropdownContext } from './Dropdown'; -import style from './Dropdown.module.scss'; import { createTokenComponent } from '@components/utils/tokenComponents'; +import useBaseComponent from '@components/BaseComponent/BaseComponent'; +import style from './Dropdown.module.scss'; export interface OptionTokenProps extends CommonDropdownSlotProps { value: string; @@ -15,6 +16,7 @@ export const Option = createTokenComponent(); export const DropdownOption: ParentComponent<{ option: ParentProps }> = (props) => { const dropdown = useContext(DropdownContext); + let element: HTMLDivElement | undefined const onClickOption = (option: ParentProps) => { dropdown?.selectOption(option.value); @@ -22,7 +24,7 @@ export const DropdownOption: ParentComponent<{ option: ParentProps { - dropdown?.registerOption(props.option.value, props.option.children, props.option.selected); + dropdown?.registerOption(props.option.value, props.option.children, element!, props.option.selected); }) onCleanup(() => { @@ -35,7 +37,6 @@ export const DropdownOption: ParentComponent<{ option: ParentProps onClickOption(props.option)} + onMouseOver={(e: MouseEvent) => (e.currentTarget as HTMLElement).focus()} class={optionClasses(props.option)} - style={props.option.style} + style={{...props.option.style}} + use:navigationActions={{'select': () => { + dropdown?.selectOption(props.option.value) + dropdown?.handleNavigationClose(); + }}} > {props.option.children} diff --git a/src/components/Basic/Dropdown/DropdownTrigger.tsx b/src/components/Basic/Dropdown/DropdownTrigger.tsx index 482cae7..d2a0dd6 100644 --- a/src/components/Basic/Dropdown/DropdownTrigger.tsx +++ b/src/components/Basic/Dropdown/DropdownTrigger.tsx @@ -35,7 +35,7 @@ export const DropdownTrigger: ParentComponent = (props) =>
dropdown?.toggle(!dropdown.open())} class={style['dropdown-trigger'] + ` ${TriggerToken?.()?.class || ''}`} style={triggerStyles()}>
- {dropdown?.options.get(dropdown?.selected())} + {dropdown?.options.get(dropdown?.selected())?.label} diff --git a/src/components/Basic/Input/InputBase/InputBase.module.scss b/src/components/Basic/Input/InputBase/InputBase.module.scss index ce54f50..03065b0 100644 --- a/src/components/Basic/Input/InputBase/InputBase.module.scss +++ b/src/components/Basic/Input/InputBase/InputBase.module.scss @@ -19,7 +19,8 @@ height: 2.2vmax; transition: border-color 0.2s ease-in-out; - &:hover { + &:hover, + &:focus { border-color: $secondaryColor; } } diff --git a/src/components/Basic/Input/InputBase/InputBase.tsx b/src/components/Basic/Input/InputBase/InputBase.tsx index 3ecdebf..a952bf0 100644 --- a/src/components/Basic/Input/InputBase/InputBase.tsx +++ b/src/components/Basic/Input/InputBase/InputBase.tsx @@ -8,7 +8,7 @@ interface InputComponentProps extends TokenComponentProps { value: Accessor, handleChange: (e: InputEvent) => void, type: 'text' | 'password' | 'number', - ref: HTMLInputElement; + ref: HTMLInputElement | ((el: HTMLInputElement) => void); hasBefore: boolean, hasAfter: boolean, } diff --git a/src/components/Basic/Input/InputBase/InputWrapper.tsx b/src/components/Basic/Input/InputBase/InputWrapper.tsx new file mode 100644 index 0000000..a9b3161 --- /dev/null +++ b/src/components/Basic/Input/InputBase/InputWrapper.tsx @@ -0,0 +1,68 @@ +import useBaseComponent from "@components/BaseComponent/BaseComponent"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions" +import baseStyles from '../InputBase/InputBase.module.scss'; +import { createMemo, onMount, ParentComponent } from "solid-js"; +import { ComponentNavigationActions } from "@components/types/ComponentProps"; +import { useNavigation } from "@components/Utility/Navigation/Navigation"; + +interface InputWrapperProps { + props: any, + refObject: any, + inputRef: { current: HTMLInputElement | undefined }; + navActions: ComponentNavigationActions +} + +const InputWrapper: ParentComponent = (wrapperProps) => { + let element!: HTMLDivElement; + const nav = useNavigation() + + const inputWrapperClasses = createMemo(() => { + const classes = [baseStyles['input-wrapper']]; + + if (wrapperProps.props.disabled) { + classes.push(baseStyles.disabled); + if (wrapperProps.props['class-disabled']) { + classes.push(wrapperProps.props['class-disabled']); + } + } + + return classes.join(' '); + }); + + onMount(() => { + if (!wrapperProps.props.ref || !element) return; + + (wrapperProps.props.ref as unknown as (ref: any) => void)({ + element, + input: wrapperProps.inputRef.current, + ...wrapperProps.refObject, + }); + }); + + wrapperProps.props.componentClasses = () => inputWrapperClasses(); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(wrapperProps.props); + + return ( +
{ + nav?.pauseNavigation(); + wrapperProps.inputRef.current?.focus(); + }, + 'back': () => { + nav?.resumeNavigation(); + wrapperProps.inputRef.current?.blur() + }, + ...wrapperProps.navActions + })}> + {wrapperProps.children} +
+ ) +} + +export default InputWrapper; \ No newline at end of file diff --git a/src/components/Basic/Input/NumberInput/NumberInput.tsx b/src/components/Basic/Input/NumberInput/NumberInput.tsx index 965cf94..94a254c 100644 --- a/src/components/Basic/Input/NumberInput/NumberInput.tsx +++ b/src/components/Basic/Input/NumberInput/NumberInput.tsx @@ -1,11 +1,10 @@ import { Input, Placeholder } from "../shared/tokens"; -import { onMount, Show, createMemo, ParentComponent, createSignal } from "solid-js"; -import useBaseComponent from "@components/BaseComponent/BaseComponent"; +import { Show, createMemo, ParentComponent, createSignal } from "solid-js"; import { createTokenComponent, TokenBase, useToken } from '@components/utils/tokenComponents'; import { InputBase } from "../InputBase/InputBase"; import { TextInputProps, TextInputRef } from "../shared/types"; import InputControlButton from "./InputControlButton"; -import baseStyles from '../InputBase/InputBase.module.scss'; +import InputWrapper from "../InputBase/InputWrapper"; import styles from './NumberInput.module.scss'; type valueType = number | string; @@ -34,8 +33,7 @@ const NumberInput: ParentComponent = (props) => { const IncreaseControlToken = useToken(IncreaseControl, props.children); const DecreaseControlToken = useToken(DecreaseControl, props.children); - let element!: HTMLDivElement; - let inputElement!: HTMLInputElement; + const inputRef = { current: undefined as HTMLInputElement | undefined }; const transformValue = (value: string) => { const isNegative = value.length && value[0] === '-'; @@ -77,12 +75,12 @@ const NumberInput: ParentComponent = (props) => { const clear = () => applyValue(''); const increaseValue = () => { - if (props.readonly) { - inputElement.value = value() as any as string; + if (props.readonly || !inputRef.current) { + if (inputRef.current) inputRef.current.value = value() as any as string; return } - const currValue = Number(inputElement.value); + const currValue = Number(inputRef.current.value); const step = props.step || 1; const { newValue } = clampValue(currValue + step); @@ -90,12 +88,12 @@ const NumberInput: ParentComponent = (props) => { } const decreaseValue = () => { - if (props.readonly) { - inputElement.value = value() as any as string; + if (props.readonly || !inputRef.current) { + if (inputRef.current) inputRef.current.value = value() as any as string; return } - - const currValue = Number(inputElement.value); + + const currValue = Number(inputRef.current.value); const step = props.step || 1; const { newValue } = clampValue(currValue - step); @@ -103,7 +101,8 @@ const NumberInput: ParentComponent = (props) => { } const applyValue = (newValue: number | string) => { - inputElement.value = newValue as any as string; + if (!inputRef.current) return; + inputRef.current.value = newValue as any as string; props.onChange?.(newValue); setValue(newValue) } @@ -131,43 +130,27 @@ const NumberInput: ParentComponent = (props) => { const showDecreaseBefore = createMemo(() => !!DecreaseControlToken() && decreaseBtnPosition() === 'before'); const showDecreaseAfter = createMemo(() => !!DecreaseControlToken() && decreaseBtnPosition() === 'after'); - - const numberInputClasses = createMemo(() => { - const classes = [baseStyles['input-wrapper']]; - - if (props.disabled) { - classes.push(baseStyles.disabled); - - if (props['class-disabled']) classes.push(`${props['class-disabled']}`); - } - - return classes.join(' '); - }); - - props.componentClasses = () => numberInputClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); - - onMount(() => { - if (!props.ref || !element) return; - - (props.ref as unknown as (ref: any) => void)({ - element, - input: inputElement, - value, - changeValue, - increaseValue, - decreaseValue, - clear - }); - }); + const refObject = { + value, + changeValue, + increaseValue, + decreaseValue, + clear + } return ( -
+ { + document.activeElement === inputRef.current && increaseValue() + }, + 'move-down': () => { + document.activeElement === inputRef.current && decreaseValue() + } + }}>
@@ -183,7 +166,7 @@ const NumberInput: ParentComponent = (props) => { inputRef.current = el} handleChange={handleChange} parentChildren={props.children} hasBefore={showIncreaseBefore() || showDecreaseBefore()} @@ -201,7 +184,7 @@ const NumberInput: ParentComponent = (props) => {
-
+ ) } diff --git a/src/components/Basic/Input/PasswordInput/PasswordInput.tsx b/src/components/Basic/Input/PasswordInput/PasswordInput.tsx index 8b7189a..ba17dc7 100644 --- a/src/components/Basic/Input/PasswordInput/PasswordInput.tsx +++ b/src/components/Basic/Input/PasswordInput/PasswordInput.tsx @@ -1,14 +1,13 @@ import { After, Before, Input } from "../shared/tokens"; -import { onMount, createMemo, ParentComponent, createSignal, Switch, Match, Accessor } from "solid-js"; -import useBaseComponent from "@components/BaseComponent/BaseComponent"; +import { createMemo, ParentComponent, createSignal, Switch, Match, Accessor } from "solid-js"; import { useToken } from '@components/utils/tokenComponents'; import { InputBase } from "../InputBase/InputBase"; import useTextInput from "../shared/useTextInput"; import { TextInputProps, TextInputRef } from "../shared/types"; import { VisibilityButton, VisibilityButtonComponent } from "./VisibilityButton"; import AddonSlot from "../shared/AddonSlot"; +import InputWrapper from "../InputBase/InputWrapper"; import styles from '../shared/TextInput.module.scss'; -import baseStyles from '../InputBase/InputBase.module.scss'; export interface PasswordInputRef extends TextInputRef { show: () => void, @@ -21,9 +20,6 @@ const PasswordInput: ParentComponent = (props) => { const AfterToken = useToken(After, props.children); const VisibilityButtonToken = useToken(VisibilityButton, props.children); - let element!: HTMLDivElement; - let inputElement!: HTMLInputElement; - const {value, handleChange, changeValue, clear } = useTextInput(props); const [type, setType] = createSignal<'text' | 'password'>('password'); const visible = createMemo(() => type() !== 'password') @@ -44,44 +40,22 @@ const PasswordInput: ParentComponent = (props) => { const visibilityPosition = createMemo(() => VisibilityButtonToken()?.position ?? 'after'); const isBefore = createMemo(() => !!VisibilityButtonToken() && visibilityPosition() === 'before'); const isAfter = createMemo(() => !!VisibilityButtonToken() && visibilityPosition() === 'after'); - - const passwordInputClasses = createMemo(() => { - const classes = [baseStyles['input-wrapper']]; - - if (props.disabled) { - classes.push(baseStyles.disabled); - - if (props['class-disabled']) classes.push(`${props['class-disabled']}`); - } - - return classes.join(' '); - }); - - props.componentClasses = () => passwordInputClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); - - onMount(() => { - if (!props.ref || !element) return; - - (props.ref as unknown as (ref: any) => void)({ - element, - input: inputElement, - value, - changeValue, - visible, - clear, - show, - hide - }); - }); + + const inputRef = { current: undefined as HTMLInputElement | undefined }; + const refObject = { + value, + changeValue, + visible, + clear, + show, + hide + } return ( -
+ @@ -98,7 +72,7 @@ const PasswordInput: ParentComponent = (props) => { inputRef.current = el} handleChange={handleChange} parentChildren={props.children} hasBefore={isBefore() || !!BeforeToken()} @@ -117,7 +91,7 @@ const PasswordInput: ParentComponent = (props) => { -
+ ) } diff --git a/src/components/Basic/Input/TextInput/TextInput.tsx b/src/components/Basic/Input/TextInput/TextInput.tsx index 8729d56..e312315 100644 --- a/src/components/Basic/Input/TextInput/TextInput.tsx +++ b/src/components/Basic/Input/TextInput/TextInput.tsx @@ -1,64 +1,37 @@ import { After, Before, Input, Placeholder } from "../shared/tokens"; -import { onMount, createMemo, ParentComponent } from "solid-js"; -import useBaseComponent from "@components/BaseComponent/BaseComponent"; +import { ParentComponent } from "solid-js"; import { useToken } from '@components/utils/tokenComponents'; import { InputBase } from "../InputBase/InputBase"; import useTextInput from "../shared/useTextInput"; import { TextInputProps } from "../shared/types"; import AddonSlot from "../shared/AddonSlot"; -import baseStyles from '../InputBase/InputBase.module.scss'; +import InputWrapper from "../InputBase/InputWrapper"; import styles from '../shared/TextInput.module.scss'; const TextInput: ParentComponent = (props) => { const BeforeToken = useToken(Before, props.children); const AfterToken = useToken(After, props.children); - let element!: HTMLDivElement; - let inputElement!: HTMLInputElement; - const {value, handleChange, changeValue, clear } = useTextInput(props); - - const textInputClasses = createMemo(() => { - const classes = [baseStyles['input-wrapper']]; - - if (props.disabled) { - classes.push(baseStyles.disabled); - - if (props['class-disabled']) classes.push(`${props['class-disabled']}`); - } - - return classes.join(' '); - }); - - props.componentClasses = () => textInputClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); - - onMount(() => { - if (!props.ref || !element) return; - - (props.ref as unknown as (ref: any) => void)({ - element, - input: inputElement, - value, - changeValue, - clear - }); - }); + const inputRef = { current: undefined as HTMLInputElement | undefined }; + const refObject = { + value, + changeValue, + clear + } return ( -
+ inputRef.current = el} handleChange={handleChange} parentChildren={props.children} hasBefore={!!BeforeToken()} @@ -67,7 +40,7 @@ const TextInput: ParentComponent = (props) => { -
+ ) } diff --git a/src/components/Basic/Slider/Slider.tsx b/src/components/Basic/Slider/Slider.tsx index 80e6394..c263709 100644 --- a/src/components/Basic/Slider/Slider.tsx +++ b/src/components/Basic/Slider/Slider.tsx @@ -9,6 +9,7 @@ import { Handle, SliderHandle } from "./SliderHandle"; import { SliderThumb, Thumb } from "./SliderThumb"; import { SliderTrack, Track } from "./SliderTrack"; import { useToken } from "@components/utils/tokenComponents"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; export interface SliderRef { value: Accessor, @@ -123,7 +124,7 @@ const Slider: ParentComponent = (props) => { } props.componentClasses = () => SliderClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); onMount(() => { if (!props.ref || !element) return; @@ -135,13 +136,19 @@ const Slider: ParentComponent = (props) => { }); }); + const defaultActions = { + 'move-left': () => changeValue(Number((value() - step()).toFixed(5))), + 'move-right': () => changeValue(Number((value() + step()).toFixed(5))), + } + return (
+ use:forwardAttrs={props} + use:navigationActions={mergeNavigationActions(props, defaultActions)}> diff --git a/src/components/Basic/Stepper/Stepper.tsx b/src/components/Basic/Stepper/Stepper.tsx index 89599e3..d9f187d 100644 --- a/src/components/Basic/Stepper/Stepper.tsx +++ b/src/components/Basic/Stepper/Stepper.tsx @@ -5,6 +5,7 @@ import useBaseComponent from "@components/BaseComponent/BaseComponent"; import { Control, StepperControl } from "./StepperControl"; import { Item, StepperItem } from "./StepperItem"; import { createTokenComponent, useToken, useTokens } from '@components/utils/tokenComponents'; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; export interface StepperRef { selected?: Accessor @@ -146,6 +147,11 @@ const Stepper: ParentComponent = (props) => { const controlsBefore = createMemo(() => props["controls-position"] === 'before'); const controlsAfter = createMemo(() => props["controls-position"] === 'after'); + const defaultActions = { + 'move-left': () => changeSelected('prev'), + 'move-right': () => changeSelected('next'), + } + return (
= (props) => { style={inlineStyles()} use:forwardEvents={props} use:forwardAttrs={props} - use:navigationActions={{ - anchor: props.anchor, - 'move-left': () => changeSelected('prev'), - 'move-right': () => changeSelected('next'), - ...props['navigation-actions'] - }} + use:navigationActions={mergeNavigationActions(props, defaultActions)} > diff --git a/src/components/Basic/TextSlider/TextSlider.tsx b/src/components/Basic/TextSlider/TextSlider.tsx index bcc3499..c2d252d 100644 --- a/src/components/Basic/TextSlider/TextSlider.tsx +++ b/src/components/Basic/TextSlider/TextSlider.tsx @@ -10,12 +10,14 @@ import { SliderThumb, Thumb } from "@components/Basic/Slider/SliderThumb"; import { SliderTrack, Track } from "@components/Basic/Slider/SliderTrack"; import { useToken } from "@components/utils/tokenComponents"; import { Pol } from "./TextSliderPol"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; export interface TextSliderRef { value: Accessor, values: Accessor element: HTMLDivElement, - changeValue: (newValue: string) => void + changeValue: (newValue: string) => void, + stepValue: (direction: 1 | -1) => void } interface TextSliderProps extends ComponentProps { @@ -130,8 +132,16 @@ const TextSlider: ParentComponent = (props) => { props.onChange?.(newValue); } + const stepValue = (direction: 1 | -1) => { + const currValues = values() + const currentIndex = currValues.indexOf(value()); + const newIndex = clamp(currentIndex + direction, 0, currValues.length - 1); + + changeValue(currValues[newIndex]); + } + props.componentClasses = () => SliderClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); onMount(() => { if (!props.ref || !element) return; @@ -140,10 +150,17 @@ const TextSlider: ParentComponent = (props) => { value, values, element, - changeValue + changeValue, + stepValue }); }); + + const defaultActions = { + 'move-left': () => stepValue(-1), + 'move-right': () => stepValue(1), + } + return (
= (props) => { class={className()} style={inlineStyles()} use:forwardEvents={props} - use:forwardAttrs={props}> + use:forwardAttrs={props} + use:navigationActions={mergeNavigationActions(props, defaultActions)}> diff --git a/src/components/Basic/ToggleButton/ToggleButton.tsx b/src/components/Basic/ToggleButton/ToggleButton.tsx index 833522d..ff135c8 100644 --- a/src/components/Basic/ToggleButton/ToggleButton.tsx +++ b/src/components/Basic/ToggleButton/ToggleButton.tsx @@ -6,6 +6,7 @@ import { Control, ToggleButtonControl } from "./ToggleButtonControl"; import { Indicator } from "./ToggleButtonIndicator"; import { createTokenComponent, useToken } from '@components/utils/tokenComponents'; import { Handle } from "./ToggleButtonHandle"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; export const LabelLeft = createTokenComponent(); export const LabelRight = createTokenComponent(); @@ -51,9 +52,9 @@ const ToggleButton: ParentComponent = (props) => { props.componentClasses = () => toggleButtonClasses(); - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); - const toggle = (e?: MouseEvent) => { + const toggle = () => { if (props.disabled) return; setChecked(prev => !prev); @@ -83,6 +84,7 @@ const ToggleButton: ParentComponent = (props) => { style={inlineStyles()} use:forwardEvents={props} use:forwardAttrs={props} + use:navigationActions={mergeNavigationActions(props, {'select': toggle})} onclick={toggle}> diff --git a/src/components/Feedback/Tooltip/tooltip.tsx b/src/components/Feedback/Tooltip/tooltip.tsx index 59eaec8..d8279d0 100644 --- a/src/components/Feedback/Tooltip/tooltip.tsx +++ b/src/components/Feedback/Tooltip/tooltip.tsx @@ -1,14 +1,16 @@ import { createMemo, createSignal, JSX, onCleanup, onMount, ParentComponent, Show, splitProps } from "solid-js"; import styles from './Tooltip.module.scss'; -import { BaseComponentRef } from "@components/types/ComponentProps"; +import { BaseComponentRef, ComponentProps } from "@components/types/ComponentProps"; import { getSafePosition } from "@components/utils/getSafePosition"; +import useBaseComponent from "@components/BaseComponent/BaseComponent"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; export interface TooltipRef extends BaseComponentRef { show: () => void, hide: () => void } -interface TooltipOptions = { message: string }> { +interface TooltipOptions = { message: string }> extends Pick { content?: (props: T) => JSX.Element position?: 'top' | 'bottom' | 'left' | 'right' | 'auto' action?: 'hover' | 'click' | 'focus' | 'none' @@ -111,10 +113,16 @@ const createTooltip = = { message: string }>(opti return classes.join(' '); }); + const { navigationActions } = useBaseComponent(props); + return (
-
+
visible() && hideTooltip() + })}> {props.children}
diff --git a/src/components/Utility/Navigation/Navigation.tsx b/src/components/Utility/Navigation/Navigation.tsx index 246591a..efd97a8 100644 --- a/src/components/Utility/Navigation/Navigation.tsx +++ b/src/components/Utility/Navigation/Navigation.tsx @@ -1,8 +1,6 @@ -import { Accessor, createContext, onCleanup, onMount, ParentComponent, useContext } from "solid-js" -// @ts-ignore +import { createContext, onCleanup, onMount, ParentComponent, useContext } from "solid-js" import { gamepad } from 'coherent-gameface-interaction-manager'; import NavigationArea from "./NavigationArea"; -import eventBus from "@components/Utility/EventBus"; import { createStore } from "solid-js/store"; import { ActionMap, NavigationConfigType } from "./types"; import { DEFAULT_ACTIONS } from "./defaults"; @@ -10,19 +8,17 @@ import createAreaMethods from "./areaMethods/useAreaMethods"; import createActionMethods from "./actionMethods/useActionMethods"; import { AreaMethods } from "./areaMethods/areaMethods.types"; import { ActionMethods } from "./actionMethods/actionMethods.types"; +import { spatialNavigation } from 'coherent-gameface-interaction-manager'; type ExcludedActionMethods = 'registerAction' | 'unregisterAction' type ExcludedAreaMethods = 'isEnabled' -interface NavigationContextType extends Omit, Omit { - _navigationEnabled: Accessor -} +interface NavigationContextType extends Omit, Omit {} export interface NavigationRef extends NavigationContextType {} export const NavigationContext = createContext(); export const useNavigation = () => { const context = useContext(NavigationContext); - if (!context) throw new Error('useNavigation must be used within Navigation'); - return context; + if (context) return context } interface NavigationProps { @@ -41,7 +37,6 @@ const Navigation: ParentComponent = (props) => { keyboard: props.keyboard ?? true, actions: {...DEFAULT_ACTIONS, ...props.actions}, scope: props.scope ?? "", - navigationEnabled: false }) const areas = new Set(); @@ -49,13 +44,12 @@ const Navigation: ParentComponent = (props) => { const { registerAction, unregisterAction, ...publicActionMethods } = createActionMethods(config, setConfig); // Create area methods and extract internal-only methods - const { isEnabled, ...publicAreaMethods } = createAreaMethods(areas, config, setConfig); + const areaMethods = createAreaMethods(areas, setConfig); // Compose public API const navigationAPI = { ...publicActionMethods, - ...publicAreaMethods, - _navigationEnabled: isEnabled + ...areaMethods, } const initActions = () => { @@ -81,7 +75,10 @@ const Navigation: ParentComponent = (props) => { (props.ref as unknown as (ref: NavigationRef) => void)(navigationAPI); }) - onCleanup(() => deInitActions()) + onCleanup(() => { + deInitActions() + spatialNavigation.deinit(); + }) return ( diff --git a/src/components/Utility/Navigation/NavigationArea.tsx b/src/components/Utility/Navigation/NavigationArea.tsx index adab151..127bc7f 100644 --- a/src/components/Utility/Navigation/NavigationArea.tsx +++ b/src/components/Utility/Navigation/NavigationArea.tsx @@ -1,5 +1,7 @@ -import { children, createEffect, on, onCleanup, onMount, ParentComponent, useContext } from "solid-js" -import { NavigationContext, useNavigation } from "./Navigation"; +import { children, createEffect, on, onCleanup, onMount, ParentComponent } from "solid-js" +import { useNavigation } from "./Navigation"; +//@ts-ignore +import { spatialNavigation } from 'coherent-gameface-interaction-manager'; interface NavigationAreaProps { name: string, selector?: string, @@ -8,12 +10,12 @@ interface NavigationAreaProps { const NavigationArea: ParentComponent = (props) => { const nav = useNavigation(); + if (!nav) throw new Error('useNavigation must be used within Navigation'); const cachedChildren = children(() => props.children); const navigatableElements = props.selector ? [`.${props.selector}`] : cachedChildren(); - let hasRegistered = false; const refresh = () => { - if (!nav._navigationEnabled()) return; + if (!spatialNavigation.enabled) return; deinit(); init(false); } @@ -28,10 +30,6 @@ const NavigationArea: ParentComponent = (props) => { // Refresh whenever children change createEffect(on(cachedChildren, refresh, { defer: true })) - createEffect(on(nav!._navigationEnabled, (v) => { - if (v && hasRegistered) init(false); - hasRegistered = true; - }, { defer: true })) onMount(() => { const shouldFocus = props.focused || props.name === nav.getScope(); diff --git a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts index 322867d..a5de7f7 100644 --- a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts @@ -1,4 +1,3 @@ -// @ts-ignore import { actions, keyboard, gamepad } from 'coherent-gameface-interaction-manager'; import { SetStoreFunction } from 'solid-js/store'; import { ActionName, ActionCfg, DefaultActions, NavigationConfigType } from '../types'; @@ -31,7 +30,7 @@ export default function createActionMethods( if (config.gamepad && button) { gamepad.on({ - actions: button.binds, + actions: button.binds as any, callback: actionName, type: button.type }); diff --git a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts index 91e8222..35d5a59 100644 --- a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts +++ b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts @@ -1,3 +1,5 @@ +import { KeyName } from "coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings"; + /** * Interface defining all area-related navigation methods */ @@ -45,7 +47,7 @@ export interface AreaMethods { * @param clearCurrent - Whether to clear current active keys before setting new ones */ changeNavigationKeys: ( - keys: { up?: string; down?: string; left?: string; right?: string }, + keys: { up?: KeyName | KeyName[], down?: KeyName | KeyName[], left?: KeyName | KeyName[], right?: KeyName | KeyName[]}, clearCurrent?: boolean ) => void; @@ -55,18 +57,12 @@ export interface AreaMethods { resetNavigationKeys: () => void; /** - * Checks if spatial navigation is currently enabled - * @returns True if navigation is enabled, false otherwise - */ - isEnabled: () => boolean; - - /** - * Pauses spatial navigation by deinitializing it + * Pauses navigation, preventing spatial navigation actions from executing */ pauseNavigation: () => void; /** - * Resumes spatial navigation by re-enabling it + * Resumes navigation, allowing spatial navigation actions to execute again */ resumeNavigation: () => void; } diff --git a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts index a2334a8..1b39b59 100644 --- a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts +++ b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts @@ -1,69 +1,46 @@ import { waitForFrames } from '@components/utils/waitForFrames'; -// @ts-ignore -import { spatialNavigation } from 'coherent-gameface-interaction-manager'; 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'; export default function createAreaMethods( areas: Set, - config: NavigationConfigType, setConfig: SetStoreFunction ): AreaMethods { - let lastActive: Element | null = null; - let lastArea: string | null = null; const registerArea = (area: string, elements: string[] | HTMLElement[], focused?: boolean) => { - const enabled = spatialNavigation.enabled; - if (!enabled) setConfig('navigationEnabled', true) - waitForFrames(() => { - spatialNavigation[enabled ? 'add' : 'init']([ + spatialNavigation[spatialNavigation.enabled ? 'add' : 'init']([ { area: area, elements: elements }, ]); areas.add(area); focused && focusFirst(area); - }, enabled ? 0 : 3) + }, spatialNavigation.enabled ? 1 : 3) } const unregisterArea = (area: string) => { spatialNavigation.remove(area) areas.delete(area); - if (areas.size === 0) { - spatialNavigation.deinit(); - setConfig('navigationEnabled', false) - } } const pauseNavigation = () => { - if (!isEnabled()) { - return console.warn('Spatial Navigation is not currently active!') + if (spatialNavigation.paused) { + return console.warn('Navigation is already paused!') } - - lastActive = document.activeElement; - lastArea = config.scope; - spatialNavigation.deinit(); - setConfig('navigationEnabled', false) + + spatialNavigation.pause(); } const resumeNavigation = () => { - if (isEnabled()) { - return console.warn('Spatial Navigation is already active!') + if (!spatialNavigation.paused) { + return console.warn('Navigation is not paused!') } - setConfig('navigationEnabled', true) - waitForFrames(() => { - if (lastActive && spatialNavigation.isElementInGroup(lastActive)) { - (lastActive as HTMLElement).focus(); - setConfig('scope', lastArea!); - } else { - focusFirst(config.scope) - } - }) + spatialNavigation.resume(); } - const isEnabled = () => config.navigationEnabled - const focusFirst = (area: string) => { if (!areas.has(area)) { console.warn(`Area "${area}" not registered. Available areas:`, Array.from(areas)); @@ -96,7 +73,7 @@ export default function createAreaMethods( const clearFocus = () => spatialNavigation.clearFocus(); - const changeNavigationKeys = (keys: { up?: string, down?: string, left?: string, right?: string}, clearCurrent = false) => { + const changeNavigationKeys = (keys: { up?: KeyName | KeyName[], down?: KeyName | KeyName[], left?: KeyName | KeyName[], right?: KeyName | KeyName[]}, clearCurrent = false) => { spatialNavigation.changeKeys(keys, { clearCurrentActiveKeys: clearCurrent }); } @@ -111,7 +88,6 @@ export default function createAreaMethods( clearFocus, changeNavigationKeys, resetNavigationKeys, - isEnabled, pauseNavigation, resumeNavigation }; diff --git a/src/components/Utility/Navigation/keybindings/keybindings.types.ts b/src/components/Utility/Navigation/keybindings/keybindings.types.ts index cdd4027..9a60a8e 100644 --- a/src/components/Utility/Navigation/keybindings/keybindings.types.ts +++ b/src/components/Utility/Navigation/keybindings/keybindings.types.ts @@ -1,110 +1,4 @@ // REPLACE WITH BINDINGS FROM IM WHEN ITS MIGRATED TO TS - -/** - * All valid keyboard key bindings supported by Gameface - * Based on coherent-gameface-interaction-manager keyboard-key-codes - */ -export type KeyBinding = - | 'ALT' - | 'ARROW_DOWN' - | 'ARROW_LEFT' - | 'ARROW_RIGHT' - | 'ARROW_UP' - | 'BACKSPACE' - | 'CAPS_LOCK' - | 'CTRL' - | 'DELETE' - | 'END' - | 'ENTER' - | 'ESC' - | 'F1' - | 'F2' - | 'F3' - | 'F4' - | 'F5' - | 'F6' - | 'F7' - | 'F8' - | 'F9' - | 'F10' - | 'F11' - | 'F12' - | 'HOME' - | 'INSERT' - | 'NUM_LOCK' - | 'NUMPAD_ENTER' - | 'NUMPAD_DASH' - | 'NUMPAD_STAR' - | 'NUMPAD_DOT' - | 'NUMPAD_FORWARD_SLASH' - | 'NUMPAD_PLUS' - | 'NUMPAD_0' - | 'NUMPAD_1' - | 'NUMPAD_2' - | 'NUMPAD_3' - | 'NUMPAD_4' - | 'NUMPAD_5' - | 'NUMPAD_6' - | 'NUMPAD_7' - | 'NUMPAD_8' - | 'NUMPAD_9' - | 'PAGE_DOWN' - | 'PAGE_UP' - | 'PAUSE' - | 'PRINT_SCRN' - | 'SCROLL_LOCK' - | 'SHIFT' - | 'SPACEBAR' - | 'TAB' - | 'A' - | 'B' - | 'C' - | 'D' - | 'E' - | 'F' - | 'G' - | 'H' - | 'I' - | 'J' - | 'K' - | 'L' - | 'M' - | 'N' - | 'O' - | 'P' - | 'Q' - | 'R' - | 'S' - | 'T' - | 'U' - | 'V' - | 'W' - | 'X' - | 'Y' - | 'Z' - | '1' - | '2' - | '3' - | '4' - | '5' - | '6' - | '7' - | '8' - | '9' - | '0' - | 'QUOTE' - | 'DASH' - | 'COMMA' - | 'DOT' - | 'FORWARD_SLASH' - | 'SEMI_COLON' - | 'SQUARE_BRACKET_LEFT' - | 'SQUARE_BRACKET_RIGHT' - | 'BACKWARD_SLASH' - | 'BACKTICK' - | 'EQUAL' - | 'SYSTEM'; - /** * All valid gamepad button bindings supported by Gameface * Includes uppercase names and lowercase aliases (xbox.*, playstation.*, etc.) diff --git a/src/components/Utility/Navigation/types.ts b/src/components/Utility/Navigation/types.ts index fa84630..eda38e1 100644 --- a/src/components/Utility/Navigation/types.ts +++ b/src/components/Utility/Navigation/types.ts @@ -1,10 +1,11 @@ -import { KeyBinding, GamepadButton } from './keybindings/keybindings.types'; +import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings'; +import { GamepadInput } from 'coherent-gameface-interaction-manager/dist/types/utils/gamepad-mappings'; type ActionType = 'press' | 'hold' | 'lift'; export type ActionCfg = { - key?: {binds: KeyBinding[], type?: ActionType[]}; - button?: {binds: GamepadButton[], type?: Exclude} + key?: {binds: KeyName[], type?: ActionType[]}; + button?: {binds: GamepadInput[], type?: Exclude} callback: (scope?: string, ...args: any[]) => void global?: boolean, paused?: boolean, @@ -23,5 +24,4 @@ export interface NavigationConfigType { keyboard: boolean, actions: ActionMap, scope: string, - navigationEnabled: boolean } \ No newline at end of file diff --git a/src/components/types/ComponentProps.d.ts b/src/components/types/ComponentProps.d.ts index ae6b982..03fbe16 100644 --- a/src/components/types/ComponentProps.d.ts +++ b/src/components/types/ComponentProps.d.ts @@ -33,20 +33,25 @@ export interface ComponentProps = {}> extends Comp ref?: unknown | ((ref: BaseComponentRef & T) => void); refObject?: T; anchor?: HTMLElement | string; - 'navigation-actions'?: NavigationActionsConfig + onAction?: ComponentNavigationActions } export interface TokenComponentProps { parentChildren: JSX.Element, } -type NavigationActionHandler = (scope?: string) => void; +type NavigationActionHandler = (scope?: string) => void; + +// Full config for the navigationActions directive (includes anchor) export type NavigationActionsConfig = { anchor?: HTMLElement | string; } & { [K in ActionName]?: NavigationActionHandler | HTMLElement | string | undefined; } +// Component prop type (excludes anchor - use the top-level anchor prop instead) +export type ComponentNavigationActions = Omit; + declare module "solid-js" { namespace JSX { interface IntrinsicElements { diff --git a/src/components/utils/mergeNavigationActions.ts b/src/components/utils/mergeNavigationActions.ts new file mode 100644 index 0000000..a84dc38 --- /dev/null +++ b/src/components/utils/mergeNavigationActions.ts @@ -0,0 +1,12 @@ +import { ComponentProps, ComponentNavigationActions, NavigationActionsConfig } from "@components/types/ComponentProps"; + +/** + * Merges component navigation actions: anchor from props, default component actions, then user's onAction (highest priority). + */ +export default function mergeNavigationActions(props: ComponentProps, componentActions: ComponentNavigationActions = {}): NavigationActionsConfig { + return { + anchor: props.anchor, + ...componentActions, + ...(props.onAction || {}) + }; +} \ No newline at end of file diff --git a/src/custom-components/Menu/CustomDropdown/_menu-theme.scss b/src/custom-components/Menu/CustomDropdown/_menu-theme.scss index a8ecd5a..499f4fa 100644 --- a/src/custom-components/Menu/CustomDropdown/_menu-theme.scss +++ b/src/custom-components/Menu/CustomDropdown/_menu-theme.scss @@ -13,7 +13,7 @@ transition: background-color .15s ease, color .15s ease, border-color .15s ease; &:hover { - background-color: $background-soft-hover; + background-color: $background-soft-hover; } } @@ -32,6 +32,16 @@ transition: background-color .15s ease, color .15s ease; } + &-option:focus, + &-option:hover { + background-color: rgba($primaryColor, 0.5); + } + + &-option-selected:focus, + &-option-selected:hover { + background-color: $primaryColor; + } + &-icon path { stroke: $disabledTextColor; transition: stroke .15s ease; diff --git a/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx b/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx index a8e25e1..4967a19 100644 --- a/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx +++ b/src/custom-components/Menu/Options/Gameplay/Gameplay.tsx @@ -20,7 +20,7 @@ const Gameplay: ParentComponent = () => { return ( - console.log('custom select implementation')}}> + console.log('custom select implementation')}}> Easy Normal From 4b28782a67beb4f56b6389dabf8845e030e3071b Mon Sep 17 00:00:00 2001 From: MartinBozhilov-coh Date: Wed, 7 Jan 2026 12:24:01 +0200 Subject: [PATCH 11/29] Resolve PR comments --- .../docs/components/Utility/Navigation.mdx | 54 +++++++++++++---- package.json | 2 +- .../Navigation/areaMethods/useAreaMethods.ts | 24 ++++---- .../keybindings/keybindings.types.ts | 58 ------------------- 4 files changed, 56 insertions(+), 82 deletions(-) delete mode 100644 src/components/Utility/Navigation/keybindings/keybindings.types.ts diff --git a/docs/src/content/docs/components/Utility/Navigation.mdx b/docs/src/content/docs/components/Utility/Navigation.mdx index abc2062..4aa5d22 100644 --- a/docs/src/content/docs/components/Utility/Navigation.mdx +++ b/docs/src/content/docs/components/Utility/Navigation.mdx @@ -21,7 +21,7 @@ This component provides: ## Usage -To use the `Navigation` component, wrap your UI with the it and define navigation areas for different sections of your UI. You can configure default actions and provide custom input bindings for your game. +To use the `Navigation` component, wrap your UI with it and define navigation areas for different sections of your UI. You can configure default actions and provide custom input bindings for your game. :::tip For detailed usage instructions, examples, and advanced features, refer to the [Guide](/components/utility/navigation#guide) section. @@ -33,8 +33,8 @@ import { ActionMap } from '@components/Navigation/Navigation/types'; const App = () => { const defaultActions: ActionMap = { - 'tab-left': {key: {binds: ['Q'], type: ['press']}, button: {binds: ['left-shoulder'], type: 'press'}, callback: menuLeft, global: true}, - 'tab-right': {key: {binds: ['E'], type: ['press']}, button: {binds: ['right-shoulder'], type: 'press'}, callback: menuRight, global: true}, + 'tab-left': {key: {binds: ['Q'], type: ['press']}, button: {binds: ['left-sholder'], type: 'press'}, callback: menuLeft, global: true}, + 'tab-right': {key: {binds: ['E'], type: ['press']}, button: {binds: ['right-sholder'], type: 'press'}, callback: menuRight, global: true}, 'select': {key: {binds: ['SPACE'], type: ['press']}, button: {binds: ['face-button-left'], type: 'press'}}, 'back': {key: {binds: ['BACKSPACE'], type: ['press']}}, } @@ -54,10 +54,9 @@ export default App; ## Default Actions -The Navigation component comes with six pre-configured default actions that handle common navigation patterns in game UIs. These actions are automatically registered when the Navigation component mounts and emit globally, making them accessible to any component within your application. +The Navigation component comes with six pre-configured default actions that handle common navigation patterns in game UIs. These actions are automatically registered when the `Navigation` component mounts and emit globally, making them accessible to any component within your application. -Default actions are constant, pre-defined actions that provide standard input mappings for navigation and interaction. -These actions are recognized across the entire Gameface UI component library, enabling preset components to work seamlessly with the Navigation system out of the box. +These actions are recognized across the entire Gameface UI component library, enabling preset components to work seamlessly with the navigation system out of the box. Each default action has: - **Keyboard bindings** - One or more keyboard keys @@ -78,7 +77,8 @@ Each default action has: | `back` | `ESC` | `face-button-right` | Cancel current operation or navigate back to the previous screen | :::caution[Reserved Action Names] -Avoid creating custom actions with names starting with `move-focus-*` (e.g., `move-focus-left`, `move-focus-right`, etc.). These names are reserved and used internally by the spatial navigation system for managing focus between UI elements. +Avoid creating custom actions with names starting with `move-focus-direction` (e.g., `move-focus-left`, `move-focus-right`, etc.). These names are reserved and used internally by the spatial navigation system for managing focus between UI elements. +[Read more here](https://frontend-tools.coherent-labs.com/interaction-manager/features/spatial-navigation/#actions) ::: ### Customizing Default Actions @@ -171,6 +171,38 @@ nav.addAction('toggle-menu', { }); ``` +### Propagating Actions (anchor element) + +Some components may need to respond to actions when a different element is focused. +You can achieve this by specifying an `anchor` element in the action configuration. When the anchor element is focused, the action will trigger for the specified element. + +:::tip +The `anchor` prop can accept either a CSS selector string or a direct reference to an `HTMLElement`. +::: + +In the following example, the `Stepper` component will respond to navigation actions when the parent `.menu-item` div is focused. + +```tsx +