Manager UI (part 4): Keyboard and gamepad navigation
4/16/2025
Martin Bozhilov
This is the fourth tutorial in the Manager UI series. In the previous tutorial, we added the “Team Roster” screen.
In this tutorial we will extendend our UI by making it fully navigatable using keyboard and gamepad.
If you wish to follow along, begin with the first tutorial of the series where we initialize and setup the project.
Additionally, you can find the complete Manager UI sample in the ${Gameface package}/Samples/uiresources/UITutorials/ManagerUI
directory.
Overview
In this tutorial we’ll add full keyboard and gamepad support by leveraging Gameface’s Interaction manager . This library makes it easy to define “focusable” zones of your UI—called navigable areas—and to wire up both keyboard and gamepad events to those zones, so users can seamlessly move around your interface using arrow keys, WASD, or a controller.
Setting up the Interaction manager
Let’s install the library in our project with:
1npm install coherent-gameface-interaction-manager
Then, in each component where you need navigation support, import only the modules you’ll use:
Setting up navigatable areas
To keep the helper interaction functions in one place, let’s create an interaction
folder in the src
.
In the index.ts
we can import all interaction manager modules we need and define an areaMappings
array which we will populate with all navigatable areas.
The Spatial Navigation module expects each area to be an object with:
area
: a string key (we’ll enforce this with a TypeScript union type)elements
: an array of CSS selectors identifying the focusable elements in that area
1// @ts-ignore2import { spatialNavigation, keyboard, gamepad, actions } from 'coherent-gameface-interaction-manager';3import { NavigatableAreaName } from '../types/types';4
5interface NavigatableArea { area: NavigatableAreaName, elements: string[] }6export const areaMappings: NavigatableArea[] = [];
By using a NavigatableAreaName
union, you get autocomplete during development and immediate feedback if you mistype an area name.
In the future we can easily extend it by adding more area names to it.
1export type NavigatableAreaName = 'grid' | 'side-nav'
Next, in the onMount hook of each component that contains focusable elements—such as your Grid
and SideNav
components—push a mapping into areaMappings
:
1onMount(() => {2 // Existing code3 areaMappings.push({area: 'grid', elements: [`.${styles.CellItem}`]})4}
1onMount(() => {2 areaMappings.push({area: 'side-nav', elements: [`.${IconStyles['Icon-Image']}`]})3})
Once all areas are registered, you can initialize the Spatial Navigation
:
1export const initSpatialNavigation = () => {2 spatialNavigation.init(areaMappings)3 // Change keys will add WASD as a valid combination for navigation4 spatialNavigation.changeKeys({ up: 'W', down: 's', left: 'a', right: 'd' });5}6
7export const deinitSpatialNavigation = () => {8 spatialNavigation.deinit();9}
And finally, create a helper to start everything and focus the first element in your sidebar:
1export const initDefaultNavigationActions = () => {2 initSpatialNavigation()3 spatialNavigation.focusFirst('side-nav')4}
Call initDefaultNavigationActions()
from your Hud.tsx
entry component (after a short delayExecution
to ensure the DOM is ready),
and you’ll immediately be able to navigate with arrow keys or WASD.
1import { initDefaultNavigationActions } from '../../Interactions/index';2const Hud = () => {3
4 setGridState('items', GridData.gridItems)5
6 delayExecution(() => {7 initDefaultNavigationActions();8 }, 4)9// rest of the component body
Setting Up Interaction State
Before wiring up input handlers, let’s define the “phases” our UI can be in and update our shared state accordingly. We’ll use three primary actions:
- Confirm - Triggered by
ENTER
(keyboard) orX
(PlayStation) /A
(Xbox) on gamepad. This will select items, begin drags/resizes, or drill into an inner screen. - Back - Triggered by
ESC
(keyboard) orO
(PlayStation) /B
(Xbox) on gamepad. This cancels the current action or pops focus back out. - Switch - Triggered by
TAB
(keyboard) orL1
(PlayStation) /LB
(Xbox) on gamepad. This toggles focus between different navigable areas (e.g. side-nav ↔ grid).
With that thought through, we can proceed with setting up the listeners for these events, we are going to achieve this with the help of the Interaction manager’s
Keyboard and
Gamepad classes
to bind these actions and emit events via
Gameface UI’s global eventBus
class,
which allows us to emit and listen for events everywhere in the application.
1. Define interaction phases
First, lets declare an TS enum with all UI phases:
1export enum PHASE {2 SIDE_NAV = "SIDE_NAV",3 GRID_IDLE = "GRID_IDLE",4 GRID_DRAG = "GRID_DRAG",5}6
7export interface gridInteractionType {8 dragging: boolean,9 resizing: boolean,10 draggedItem: Item | null,11 dropPosition: Position | null,12 isSwapCooldown: boolean,13 shouldDrag: boolean,14 draggedRow: HTMLDivElement | null,15 phase: PHASE16}
Then initialize the store in store/gridInteractionStore.ts
, defaulting to the sidebar being in focus:
1export const gridInteractionState = createMutable<gridInteractionType>({2 dragging: false,3 resizing: false,4 draggedItem: null,5 dropPosition: null,6 isSwapCooldown: false,7 shouldDrag: true,8 draggedRow: null,9 phase: PHASE.SIDE_NAV,10});
2. Hook up global switch events
In the src/Interactions/index.ts, register keyboard and gamepad listeners that emit semantic events:
To switch areas we will add press event on keyboard tab or the gamepad right sholder button.
1export const initDefaultNavigationActions = () => {2 initSpatialNavigation();3 spatialNavigation.focusFirst('side-nav');4
5 // Switch areas (TAB / L1)6 keyboard.on({7 keys: ['TAB'],8 callback: () => {9 handleTabSwitch();10 },11 type: ['press']12 });13
14 gamepad.on({15 actions: [4], // playstation.l1 / xbox.lb16 callback: () => {17 handleTabSwitch();18 },19 type: 'press',20 })21
22}
We emit confirm-pressed
and back-pressed
events so components can react in a decoupled way.
The handleTabSwitch
function will inspect gridInteractionState.phase
and call spatialNavigation.switchArea(...)
to toggle focus on the area.
1const handleTabSwitch = () => {2 let areaToFocus: NavigatableAreaName = 'side-nav';3 switch (gridInteractionState.phase) {4 case PHASE.SIDE_NAV:5 areaToFocus = 'grid';6 gridInteractionState.phase = PHASE.GRID_IDLE;7 break;8 case PHASE.GRID_IDLE:9 areaToFocus = 'side-nav';10 gridInteractionState.phase = PHASE.SIDE_NAV;11 break;12 case PHASE.GRID_DRAG:13 return;14 }15
16 spatialNavigation.switchArea(areaToFocus);17}
3. Hook up global confirm and back events
We’ll also add the confirm and back events the same way we did with the switch event:
We’ll bind ENTER
on the keyboard and face-button-down
on the gamepad for the confirm action, and ESC
on the keyboard and face-button-right
on the gamepad
for the back action.
1export const initDefaultNavigationActions = () => {2 //TAB SWITCH LOGIC HERE3
4 // Confirm (ENTER / X or A)5 keyboard.on({6 keys: ['ENTER'],7 callback: () => {8 eventBus.emit('confirm-pressed');9 },10 type: ['press']11 })12
13 gamepad.on({14 actions: [0], // playstation.x / xbox.a15 callback: () => {16 eventBus.emit('confirm-pressed');17 },18 type: 'press',19 })20
21 // Back (ESC / O or B)22 keyboard.on({23 keys: ['ESC'],24 callback: () => {25 eventBus.emit('back-pressed');26 },27 type: ['press']28 })29
30 gamepad.on({31 actions: [1], // playstation.circle / xbox.b32 callback: () => {33 eventBus.emit('back-pressed');34 },35 type: 'press',36 })37}
4. Hook up global remove events
One more thing we have to setup is a ‘remove’ event, which will be responsible for removing a grid item.
We’ll bind Backspace
on the keyboard and face-button-up
on the gamepad for the remove action.
1export const initDefaultNavigationActions = () => {2 // Rest of the events3 keyboard.on({4 keys: ['Backspace'],5 callback: removeItemWithKey,6 type: ['press']7 })8
9 gamepad.on({10 actions: [3], // playstation.triangle / xbox.y11 callback: removeItemWithKey,12 type: 'press',13 })14}
And we can directly implement the removeItemWithKey
handler function. The logic will be fairly simple:
- If the state is
GRID_IDLE
we want to remove the item we are currently focusing. - Focus the next available item on the grid.
- If there are no items focus on the side navigatio and change the interaction state to
SIDE_NAV
1const removeItemWithKey = () => {2 const item = gridInteractionState.focusedItem;3
4 if (item && gridInteractionState.phase === PHASE.GRID_IDLE) {5 removeItem(item.id);6 const nextAvailableItem = gridState.items.find((i) => i.active);7
8 if (nextAvailableItem) {9 gridState.itemRefs.get(nextAvailableItem.id)?.focus();10 return;11 }12
13 spatialNavigation.switchArea('side-nav');14 gridInteractionState.phase = PHASE.SIDE_NAV;15 }16}
Adding keyboard and gamepad interactions to the Grid
Now that our global confirm/back/tab handlers are in place, it’s time to wire up the Grid
component so that it responds to those events and lets the user drag, move,
and resize items via keyboard or gamepad.
1. Define key and gamepad-mapping arrays
At the top of Grid.tsx
, define two arrays that pair directional inputs with our internal “up/down/left/right” meanings:
1const keyMovements: {keys: string[], direction: Direction}[] = [2 {keys: ['W', 'ARROW_UP'], direction: 'up'},3 {keys: ['S', 'ARROW_DOWN'], direction: 'down'},4 {keys: ['A', 'ARROW_LEFT'], direction: 'left'},5 {keys: ['D', 'ARROW_RIGHT'], direction: 'right'},6];7
8const gamepadMappings: {key: string, direction: Direction}[] = [9 {key: 'playstation.d-pad-up', direction: "up"},10 {key: 'playstation.d-pad-down', direction: "down"},11 {key: 'playstation.d-pad-left', direction: "left"},12 {key: 'playstation.d-pad-right', direction: "right"},13];
These will be used when we begin dragging/resizing to register actions.
2. Listen for global confirm/back events
Inside your onMount
/onCleanup
in Grid.tsx, hook into the eventBus
so we can react when the user presses confirm or back:
We must also clean this handlers in the onCleanup
event to avoid memory leaks.
1onMount(() => {2 delayExecution(() => {3 areaMappings.push({area: 'grid', elements: [`.${styles.CellItem}`]})4 eventBus.on('confirm-pressed', handleConfirm);5 eventBus.on('back-pressed', handleBack);6 })7})8
9onCleanup(() => {10 window.removeEventListener("mousemove", handleMouseMove);11 window.removeEventListener("mouseup", handleMouseUp);12
13 eventBus.off('confirm-pressed', handleConfirm);14 eventBus.off('back-pressed', handleBack);15});
3. Track the focused grid item
Modify your grid‐interaction store (store/gridInteractionStore.ts
) to include a focusedItem
:
Another thing we have to setup before implementing any logic is to add a focusedItem
state to our gridInteractionState
store and add an onFocus
event to set the focused item
state when the item is focused. This way we will be able to extract the properties of the focused item when we need them.
1export interface gridInteractionType {2 // …existing fields…3 focusedItem: Item | null;4 phase: InteractionPhase;5}6
7export const gridInteractionState = createMutable<gridInteractionType>({8 // …existing initial values…9 focusedItem: null,10 phase: 'SIDE_NAV',11});
Now let’s now add the onFocus
event in the grid component, where we loop through all items and render them.
We’ll also disable inactive items so Spatial Navigation skips them:
10 collapsed lines
1<For each={gridState.items} >2 {(item) => {3 const ScreenComponent = screenRegistry[item.name];4 return (5 <div6 ref={(el) => gridState.itemRefs.set(item.id, el)}7 style={{8 top: `${item.y}px`,9 left: `${item.x}px`,10 }}11 class={`${styles[`Item-${item.sizeX}x${item.sizeY}`]} ${styles.CellItem} ${item.active ? styles.Active : styles.Inactive}`}12 bool:disabled={!item.active}13 onFocus={() => gridInteractionState.focusedItem = item}14 onMouseDown={(e) => handleMouseDown(e, item)}>15 <Dynamic component={ScreenComponent} name={item.name} />16 <div class={styles['CellItem-drag']} ></div>17 <div class={styles['CellItem-close']} onClick={() => removeItem(item.id)}>18 <div class={styles.CloseIcon}>X</div>19 </div>20 </div>21 )22 }}23</For>
4. Handle “Confirm” in the grid
We can proceed with setting up the handleConfirm
function.
1const handleConfirm = () => {2 switch (gridInteractionState.phase) {3 case PHASE.SIDE_NAV:4 eventBus.emit('side-nav-item-selected')5 break;6 case PHASE.GRID_IDLE:7 // Start dragging/move-resize mode8 initGridItemDrag(gridInteractionState.focusedItem!);9 gridInteractionState.phase = PHASE.GRID_DRAG;10 break;11 case PHASE.GRID_DRAG:12 // Switch to inner screen focus13 deinitGridItemDrag();14 selectInnerScreen(gridInteractionState.focusedItem!);15 break;16 }17}
First, we need to implement initGridItemDrag
. In this function we are going to deinit the spatial navigation and register new
Interaction manager actions
that we will then register to the relevant keys and buttons from our mapping arrays.
The reason we need to deinit the spatial navigation is that we want to use the same keys that are assigned by the spatial navigation when it is initialized.
The actions
need to be assigned a name and a callback which will be executed when the action is called:
1const initGridItemDrag = (item: Item) => {2 gridState.itemRefs.get(item.id)?.classList.add(styles.Moving)3
4 batch(() => {5 gridInteractionState.dragging = true;6 gridInteractionState.draggedItem = item;7 gridInteractionState.dropPosition = {col: item.col, row: item.row};8 })9
10 deinitSpatialNavigation();11 keyMovements.forEach(({keys, direction}) => {12 actions.register(`move-item-${direction}`, (e: KeyboardEvent) => handleKeyMove(e, item, direction))13 keys.forEach((key) => {14 keyboard.on({15 keys: [key],16 callback: `move-item-${direction}`,17 type: ['press']18 });19 })20 })21
22 gamepadMappings.forEach(({key, direction}) => {23 actions.register(`resize-item-${direction}`, () => resizeItem(item, direction));24
25 gamepad.on({26 actions: [key, 'playstation.r1'],27 callback: `resize-item-${direction}`,28 type: 'hold',29 })30 gamepad.on({31 actions: [key],32 callback: `move-item-${direction}`,33 type: 'press',34 })35 })36}
In the handleKeyMove
function we are going to check if the ctrl
key has been pressed and if it has been we will trigger item resize. On the gamepad we cannot do this like that
because there is no event information. Instead we will just add 2 different key combinations.
If one of the d-pad
keys is pressed in combination with the r1
button, then we are going to resize, otherwise just move.
First let’s add the handleKeyMove
implementation:
1const handleKeyMove = (event: KeyboardEvent | null, item: Item, direction: Direction) => {2 if (event && event.ctrlKey) {3 resizeItem(item, direction);4 return;5 }6
7 moveItem(item, direction);8 updateDropPosition(item, item.x, item.y);9 setGridState('items', i => i.id === item.id, produce((i) => {10 i.col = gridInteractionState.dropPosition!.col;11 i.row = gridInteractionState.dropPosition!.row;12 }));13}14
15const moveItem = (item: Item, direction: Direction) => {16 const {cellWidth, cellHeight, gapX, gapY, maxWidth, maxHeight, minWidth, minHeight} = gridState.config;17 let newX = item.x;18 let newY = item.y;19
20 switch (direction) {21 case 'right':22 newX = clamp(item.x + (cellWidth + gapX), maxWidth, minWidth);23 break;24 case 'left':25 newX = clamp(item.x - (cellWidth + gapX), maxWidth, minWidth);26 break;27 case 'up':28 newY = clamp(item.y - (cellHeight + gapY), maxHeight, minHeight);29 break;30 case 'down':31 newY = clamp(item.y + (cellHeight + gapY), maxHeight, minHeight);32 break;33 }34
35 setGridState('items', i => i.id === item.id, produce((i) => {36 i.x = newX;37 i.y = newY;38 }));39};40
41const updateDropPosition = (item: Item, left: number, top: number) => {42 const { col, row } = getGridPosition(left, top, MAXCOLS, MAXROWS)43
44 const prevPosition = gridInteractionState.dropPosition;45 if (!prevPosition || row !== prevPosition.row || col !== prevPosition.col) {46 gridInteractionState.dropPosition = {col, row}47 }48
49 if (!memoizedCellValidation()) {50 handleOverlap(row, col, item)51 gridInteractionState.dropPosition = { col: item.col, row: item.row }52 }53}
The dragging logic is mostly reused from the mouse move event we set up in the first tutorial of this series, with the exception that instead of checking for the mouse location
we are moving one gridCell
to the direction that has been pressed. To determine if the new position of the item we are dragging is valid we are going to take the exact same
piece of logic from the mousemove
event and put it in a separate updateDropPosition
function. As a bonus we can now use this new function to simplify the mouseMoveHandler
function.
1const handleMouseMove = (e: MouseEvent) => {21 collapsed lines
2 if (!gridInteractionState.draggedItem || !gridRef) return;3
4 const dx = Math.abs(e.clientX - startMouseX);5 const dy = Math.abs(e.clientY - startMouseY);6 // Only trigger actions if the pointer has moved enough7 if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return;8
9 const mouseX = e.clientX10 const mouseY = e.clientY11
12 const itemleft = mouseX - offsetX;13 const itemTop = mouseY - offsetY;14
15 const currentItem = gridInteractionState.draggedItem;16
17 gridState.itemRefs.get(currentItem!.id)?.classList.add(styles.Dragging)18 // Pass them as fallback to return to prev place19 const currentItemRow = currentItem?.row20 const currentItemCol = currentItem?.col21
22 if (gridInteractionState.dragging && currentItem) {23 updateDropPosition(currentItem, itemleft, itemTop)24 changeItemPosition(currentItem, mouseX, mouseY);25 const { col, row } = getGridPosition(itemleft, itemTop, MAXCOLS, MAXROWS)26
27 changeItemPosition(currentItem!, mouseX, mouseY);28
29 const prevPosition = gridInteractionState.dropPosition;30 if (!prevPosition || row !== prevPosition.row || col !== prevPosition.col) {31 gridInteractionState.dropPosition = {col, row}32 }33
34 if (!memoizedCellValidation()) {35 handleOverlap(row, col, currentItem)36 gridInteractionState.dropPosition = { col: currentItemCol, row: currentItemRow }37 }38 }6 collapsed lines
39
40 if (gridInteractionState.resizing && currentItem) {41 const { col, row } = getGridPosition(itemleft, itemTop, MAXCOLS - 1, MAXROWS - 1)42 changeSize(currentItem, mouseX, mouseY, col, row);43 gridInteractionState.dropPosition = { col: currentItemCol, row: currentItemRow }44 }45};
Now let’s implement the resize function. The function is fairly simple, we are just taking the current size X or Y of the item and check if the position with this new size is valid (it is not overlapping with other elements) and if it is - we update the state of the item.
1const resizeItem = (item: Item, direction: Direction) => {2 let newSizeX = item.sizeX;3 let newSizeY = item.sizeY;4
5 switch (direction) {6 case 'right':7 newSizeX = item.sizeX + 1;8 break;9 case 'left':10 newSizeX = item.sizeX - 1;11 break;12 case 'up':13 newSizeY = item.sizeY - 1;14 break;15 case 'down':16 newSizeY = item.sizeY + 1;17 break;18 }19
20 const { col, row } = getGridPosition(item.x, item.y, MAXCOLS - 1, MAXROWS - 1)21
22 if (isCellBlockValid(row, col, item, newSizeX, newSizeY)) {23 setGridState('items', i => i.id === item.id, produce((i) => {24 i.sizeX = clamp(newSizeX, i.maxSizeX, i.minSizeX);25 i.sizeY = clamp(newSizeY, i.maxSizeY, i.minSizeY);26 }));27 gridInteractionState.dropPosition = {col, row}28 }29}
We can now use the keyboard and gamepad to drag around and resize the grid items!
Next, we’ll need to handle when the user clicks confirm
again. We want to end the drag state and focus in on the selected element’s screen.
We already set up the function we need to call for this to happen - deinitGridItemDrag
and selectInnerScreen
. For now we’ll just setup the former. In the deinit function
we will reuse the handleMouseUp
handler to deinit dragging, unregister the move-item
actions and keyboard/gamepad events and finally reinit the spatial navigation.
1const deinitGridItemDrag = () => {2 handleMouseUp();3 gridInteractionState.phase = PHASE.GRID_IDLE;4 //deinit keyboard5 keyMovements.forEach(({keys, direction}) => {6 keys.forEach((key) => keyboard.off([key]));7 actions.remove(`move-item-${direction}`);8 })9 //deinit gamepad10 gamepadMappings.forEach(({key, direction}) => {11 gamepad.off([key, 'playstation.r1']);12 gamepad.off([key]);13 actions.remove(`resize-item-${direction}`);14 })15 initSpatialNavigation();16}
We can now proceed with implementing the opposite interactions - pressing back.
5. Handle “Back” in the grid
We’ve already referenced the handleBack
handler, what’s left is to implement it. When the phase is either SIDE_NAV
or GRID_IDLE
we’ll just return without doing anything.
When the phase is GRID_DRAG
we are going to again end the dragging state, as well as focus back on the same item, in order to not lose the focus.
1const handleBack = () => {2 switch (gridInteractionState.phase) {3 case PHASE.SIDE_NAV:4 case PHASE.GRID_IDLE:5 break;6 case PHASE.GRID_DRAG:7 // Switch to inner screen focus8 deinitGridItemDrag();9 gridState.itemRefs.get(gridInteractionState.focusedItem!.id)!.focus();10 gridInteractionState.phase = PHASE.TEAM_ROSTER_TABLE;11 break;12 }13};
With all of the above in place, your Grid component will now:
- React to Confirm by entering drag/resize or drilling in
- Let users move items one cell at a time with arrow/WASD or D-pad
- Resize with Ctrl+arrows (keyboard) or D-pad+R1 (gamepad)
- Cancel any drag/resize with Back (Esc/B)
- Restore full spatial navigation once the action completes
Listening for events in the navigation
When the user confirms while focused on the sidebar (SIDE_NAV
), we emit a side-nav-item-selected
event.
Next, we’ll make the SideNav
component react by adding the selected icon to the grid and moving focus forward.
-
Track the focused icon
InSideNav.tsx
, add aselectedItem
signal to remember whichNavIcon
was last focused. -
Handle keyboard activation
Create ahandleKeyInteraction
callback that calls your existingclickHandler
usingselectedItem()
. -
Subscribe/unsubscribe to the eventBus
InonMount
, registerside-nav-item-selected → handleKeyInteraction
; inonCleanup
, remove that listener. -
Wire up
NavIcon
PasssetSelectedItem
down to each<NavIcon>
, then inNavIcon.tsx
addfocus
andblur
handlers to update the signal and show/hide the tooltip.
1import { areaMappings, focusNext } from '../../Interactions/index';2import eventBus from '@components/tools/EventBus';3
4const SideNav: Component = () => {5 const [toastMessage, setToastMessage] = createSignal("");6 const [showError, setShowError] = createSignal<boolean>(false);7 const [selectedItem, setSelectedItem] = createSignal<Item | null>(null);8
22 collapsed lines
9 const imageMap = new Map<number, string>([10 [1, teamRosterIcon],11 [2, matchTacticsIcon],12 [3, teamSettingsIcon],13 [4, playerStatsIcon],14 [5, matchCalendarIcon],15 [6, teamMoraleIcon],16 [7, financialsIcon],17 [8, transferMarketIcon],18 [9, matchStatsIcon],19 [10, teamNewsIcon],20 [11, scoutingIcon],21 [12, managerStats],22 ]);23
24 const clickHandler = (item: Item) => {25 const isAdded = addItem(item.id);26 setShowError(false); // reset first27
28 if (!isAdded) {29 setToastMessage(`Unable to show ${item.name} window`);30 setTimeout(() => setShowError(true), 0);31 } else {32 setShowError(false);33 focusNext();34 }35 }36
37 const handleKeyInteraction = () => {38 clickHandler(selectedItem()!);39 }40
41 onMount(() => {42 areaMappings.push({area: 'side-nav', elements: [`.${IconStyles['Icon-Image']}`]})43 eventBus.on('side-nav-item-selected', handleKeyInteraction)44 })45
46 onCleanup(() => {47 eventBus.off('side-nav-item-selected', handleKeyInteraction)48 })49
50 return (51 <Flex direction='column' justify-content='start' align-items='center' class={styles.SideBar}>52 <Block class={styles.Logo}>53 <Image src={GameLogo} class={styles.LogoImage} />54 </Block>55 <Flex direction='column'>56 <For each={gridState.items}>57 {(item) => <NavIcon item={item} imageMap={imageMap} click={clickHandler} />}58 {(item) => <NavIcon item={item} imageMap={imageMap} click={clickHandler} setSelectedItem={setSelectedItem} />}59 </For>60 </Flex>61 <Toast show={showError()} onHide={() => setShowError(false)}>62 <Flex align-items='center'>63 <Image src={cancelIcon} class={styles['Error-Icon']} />64 <TextBlock>{toastMessage()}</TextBlock>65 </Flex>66 </Toast>67 </Flex>68 )69}70
71export default SideNav
1type NavIconProps = {2 item: Item,3 imageMap: Map<number, string>,4 click: (item: Item) => void,5 setSelectedItem: Setter<Item | null>6}7
8const NavIcon: Component<NavIconProps> = ({item, imageMap, click, setSelectedItem}) => {9 const [showTooltip, setShowTooltip] = createSignal(false);10
11 const handleFocus = () => {12 setShowTooltip(true);13 setSelectedItem(item);14 }15
14 collapsed lines
16 return (17 <Block class={`${styles.Icon} ${item.active ? styles['Icon-Disabled'] : ''}`} >18 <Tooltip show={showTooltip()}>19 <Flex align-items="center">20 <Image src={infoIcon} class={styles.InfoIcon} />21 <TextBlock style={{margin: 0}}>{item.name}</TextBlock>22 </Flex>23 </Tooltip>24 <Image25 class={`${styles['Icon-Image']} ${item.active ? styles['Icon-Image-Disabled'] : ''}`}26 bool:disabled={item.active}27 click={() => click(item)}28 mouseenter={() => setShowTooltip(true)}29 mouseleave={() => setShowTooltip(false)}30 focus={handleFocus}31 blur={() => setShowTooltip(false)}32 src={imageMap.get(item.id)!} />33 </Block>34 )35}36
37export default NavIcon
We’ve also added a focusNext
utility function which is called if an item has been added successfully to the grid and will just focus the next focusable element in the area.
1export const focusNext = () => {2 actions.execute("move-focus-down")3}
With that in place, confirming on a focused icon adds it to the grid and then calls focusNext()
to advance the highlight.
Adding Controller Button Tips
To help users remember which buttons do what in each phase, we’ll display context-aware hints in the bottom-right corner.
- Define your mappings
In src/Interactions/gamepadMappings.ts
, export a gamepadMappings
object keyed by each InteractionPhase
:
1import aButton from "@assets/gamepad-controls/a-filled-green.svg"2import bButton from "@assets/gamepad-controls/b-filled-red.svg"3import rb from "@assets/gamepad-controls/right-bumper.svg"4import lb from "@assets/gamepad-controls/left-bumper.svg"5import rJoystick from "@assets/gamepad-controls/right-joystick.svg"6import { PHASE } from "../types/types"7
8type GameMappingsType = {9 [K in keyof typeof PHASE]: { icon: string; text: string }[];10};11
12export const gamepadMappings: GameMappingsType = {13 SIDE_NAV: [14 { icon: aButton, text: 'Add Item' },15 { icon: lb, text: 'Swtich tab' },16 ],17 GRID_IDLE: [18 { icon: aButton, text: 'Select Item' },19 { icon: yButton, text: 'Remove Item' },20 { icon: lb, text: 'Swtich tab' },21 ],22 GRID_DRAG: [23 { icon: aButton, text: 'Enter screen' },24 { icon: bButton, text: 'Back' },25 { icon: rb, text: 'Hold to resize' },26 ],27}
- Expose the current set In src/Interactions/index.ts, add:
1export const getCurrentGamepadMapping = () => {2 return gamepadMappings[gridInteractionState.phase]3}
- Build the
ButtonTips
component Createcustom-components/Util/ButtonTips.tsx
:
7 collapsed lines
1import Absolute from "@components/Layout/Absolute/Absolute";2import Flex from "@components/Layout/Flex/Flex";3import { For } from "solid-js";4import { getCurrentGamepadMapping } from "../../Interactions";5import Image from "@components/Media/Image/Image";6import TextBlock from "@components/Basic/TextBlock/TextBlock";7
8const ButtonTips = () => {9 return (10 <Absolute bottom="0" right="0" style={{color: "white", "font-size": '1vmax', "text-transform": 'uppercase', 'font-weight': 'bold', 'z-index': '9999999'}}>11 <Flex align-items="center">12 <For each={getCurrentGamepadMapping()} >13 {(item) => (14 <Flex align-items="center" style={{"margin-right": "1vmax"}}>15 <Image src={item.icon} style={{"width": "1.5vmax", "height": "1.5vmax", "margin-right": '0.25vmax'}}></Image>16 <TextBlock>{item.text}</TextBlock>17 </Flex>18 )}19 </For>20 </Flex>21 </Absolute>22 )23}24
25export default ButtonTips;
- Render in your HUD
Finally, import and include
<ButtonTips />
inviews/hud/Hud.tsx
:
1import ButtonTips from '@custom-components/Util/ButtonTips';2const Hud = () => {16 collapsed lines
3
4 setGridState('items', GridData.gridItems)5
6 gamepad.enabled = true;7 gamepad.pollingInterval = 100;8 delayExecution(() => {9 initDefaultNavigationActions();10 }, 4)11
12 return (13 <Layout>14 <Row class={styles.Hud} >15 <Column1>16 <SideNav />17 </Column1>18 <Column11>19 <Grid />20 </Column11>21 </Row>22 <ButtonTips />23 </Layout>24 );25};
Now players will always see the relevant gamepad hints—ADD, SELECT, BACK, SWITCH and RESIZE—right when they need them!
Conclusion
We’ve successfully implemented full keyboard and gamepad navigation in our UI using Gameface’s Interaction Manager. The system is modular, easily extendable, and sets a strong foundation for adding interaction support to additional screens.
In the next part of this tutorial series, we’ll expand this system to include the Team Roster screen and explore how to handle more complex input flows across multiple components.