Manager UI (part 4): Keyboard and gamepad navigation

ui tutorials

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:

Terminal window
1
npm 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
src/Interactions/index.ts
1
// @ts-ignore
2
import { spatialNavigation, keyboard, gamepad, actions } from 'coherent-gameface-interaction-manager';
3
import { NavigatableAreaName } from '../types/types';
4
5
interface NavigatableArea { area: NavigatableAreaName, elements: string[] }
6
export 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.

types/types.ts
1
export 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:

custom-components/Grid/Grid.tsx
1
onMount(() => {
2
// Existing code
3
areaMappings.push({area: 'grid', elements: [`.${styles.CellItem}`]})
4
}
custom-components/Navigation/SideNav.tsx
1
onMount(() => {
2
areaMappings.push({area: 'side-nav', elements: [`.${IconStyles['Icon-Image']}`]})
3
})

Once all areas are registered, you can initialize the Spatial Navigation:

src/Interactions/index.ts
1
export const initSpatialNavigation = () => {
2
spatialNavigation.init(areaMappings)
3
// Change keys will add WASD as a valid combination for navigation
4
spatialNavigation.changeKeys({ up: 'W', down: 's', left: 'a', right: 'd' });
5
}
6
7
export const deinitSpatialNavigation = () => {
8
spatialNavigation.deinit();
9
}

And finally, create a helper to start everything and focus the first element in your sidebar:

src/Interactions/index.ts
1
export 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.

views/hud/Hud.ts
1
import { initDefaultNavigationActions } from '../../Interactions/index';
2
const 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) or X (PlayStation) / A (Xbox) on gamepad. This will select items, begin drags/resizes, or drill into an inner screen.
  • Back - Triggered by ESC (keyboard) or O (PlayStation) / B (Xbox) on gamepad. This cancels the current action or pops focus back out.
  • Switch - Triggered by TAB (keyboard) or L1 (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:

types/types.ts
1
export enum PHASE {
2
SIDE_NAV = "SIDE_NAV",
3
GRID_IDLE = "GRID_IDLE",
4
GRID_DRAG = "GRID_DRAG",
5
}
6
7
export 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: PHASE
16
}

Then initialize the store in store/gridInteractionStore.ts, defaulting to the sidebar being in focus:

store/gridInteractionStore.ts
1
export 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.

src/Interactions/index.ts
1
export 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.lb
16
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.

Interactions/index.ts
1
const 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.

src/Interactions/index.ts
1
export const initDefaultNavigationActions = () => {
2
//TAB SWITCH LOGIC HERE
3
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.a
15
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.b
32
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.

src/Interactions/index.ts
1
export const initDefaultNavigationActions = () => {
2
// Rest of the events
3
keyboard.on({
4
keys: ['Backspace'],
5
callback: removeItemWithKey,
6
type: ['press']
7
})
8
9
gamepad.on({
10
actions: [3], // playstation.triangle / xbox.y
11
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
src/Interactions/index.ts
1
const 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:

custom-components/Grid/Grid.tsx
1
const 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
8
const 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.

custom-components/Grid/Grid.tsx
1
onMount(() => {
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
9
onCleanup(() => {
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.

store/gridInteractionStore.ts
1
export interface gridInteractionType {
2
// …existing fields…
3
focusedItem: Item | null;
4
phase: InteractionPhase;
5
}
6
7
export 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:

custom-components/Grid/Grid.tsx
10 collapsed lines
1
<For each={gridState.items} >
2
{(item) => {
3
const ScreenComponent = screenRegistry[item.name];
4
return (
5
<div
6
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.

custom-components/Grid/Grid.tsx
1
const 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 mode
8
initGridItemDrag(gridInteractionState.focusedItem!);
9
gridInteractionState.phase = PHASE.GRID_DRAG;
10
break;
11
case PHASE.GRID_DRAG:
12
// Switch to inner screen focus
13
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:

custom-components/Grid/Grid.tsx
1
const 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:

custom-components/Grid/Grid.tsx
1
const 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
15
const 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
41
const 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.

custom-components/Grid/Grid.tsx
1
const 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 enough
7
if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return;
8
9
const mouseX = e.clientX
10
const mouseY = e.clientY
11
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 place
19
const currentItemRow = currentItem?.row
20
const currentItemCol = currentItem?.col
21
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.

custom-components/Grid/Grid.tsx
1
const 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.

custom-components/Grid/Grid.tsx
1
const deinitGridItemDrag = () => {
2
handleMouseUp();
3
gridInteractionState.phase = PHASE.GRID_IDLE;
4
//deinit keyboard
5
keyMovements.forEach(({keys, direction}) => {
6
keys.forEach((key) => keyboard.off([key]));
7
actions.remove(`move-item-${direction}`);
8
})
9
//deinit gamepad
10
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.

custom-components/Grid/Grid.tsx
1
const 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 focus
8
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.

  1. Track the focused icon
    In SideNav.tsx, add a selectedItem signal to remember which NavIcon was last focused.

  2. Handle keyboard activation
    Create a handleKeyInteraction callback that calls your existing clickHandler using selectedItem().

  3. Subscribe/unsubscribe to the eventBus
    In onMount, register side-nav-item-selected → handleKeyInteraction; in onCleanup, remove that listener.

  4. Wire up NavIcon
    Pass setSelectedItem down to each <NavIcon>, then in NavIcon.tsx add focus and blur handlers to update the signal and show/hide the tooltip.

custom-components/Navigation/SideNav.tsx
1
import { areaMappings, focusNext } from '../../Interactions/index';
2
import eventBus from '@components/tools/EventBus';
3
4
const 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 first
27
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
71
export default SideNav
custom-components/Navigation/NavIcon.tsx
1
type NavIconProps = {
2
item: Item,
3
imageMap: Map<number, string>,
4
click: (item: Item) => void,
5
setSelectedItem: Setter<Item | null>
6
}
7
8
const 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
<Image
25
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
37
export 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.

src/Interactions/index.ts
1
export 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.

  1. Define your mappings

In src/Interactions/gamepadMappings.ts, export a gamepadMappings object keyed by each InteractionPhase:

src/Interactions/gamepadMappings.ts
1
import aButton from "@assets/gamepad-controls/a-filled-green.svg"
2
import bButton from "@assets/gamepad-controls/b-filled-red.svg"
3
import rb from "@assets/gamepad-controls/right-bumper.svg"
4
import lb from "@assets/gamepad-controls/left-bumper.svg"
5
import rJoystick from "@assets/gamepad-controls/right-joystick.svg"
6
import { PHASE } from "../types/types"
7
8
type GameMappingsType = {
9
[K in keyof typeof PHASE]: { icon: string; text: string }[];
10
};
11
12
export 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
}
  1. Expose the current set In src/Interactions/index.ts, add:
src/Interactions/index.ts
1
export const getCurrentGamepadMapping = () => {
2
return gamepadMappings[gridInteractionState.phase]
3
}
  1. Build the ButtonTips component Create custom-components/Util/ButtonTips.tsx:
custom-components/Util/ButtonTips.tsx
7 collapsed lines
1
import Absolute from "@components/Layout/Absolute/Absolute";
2
import Flex from "@components/Layout/Flex/Flex";
3
import { For } from "solid-js";
4
import { getCurrentGamepadMapping } from "../../Interactions";
5
import Image from "@components/Media/Image/Image";
6
import TextBlock from "@components/Basic/TextBlock/TextBlock";
7
8
const 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
25
export default ButtonTips;
  1. Render in your HUD Finally, import and include <ButtonTips /> in views/hud/Hud.tsx:
views/hud/Hud.tsx
1
import ButtonTips from '@custom-components/Util/ButtonTips';
2
const 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.

On this page