Manager UI (part 5): Team Roster - Keyboard and gamepad navigation

ui tutorials

4/17/2025

Martin Bozhilov

This is the fifth tutorial in the Manager UI series. In the previous tutorial, we implemented full keyboard and gamepad support using Gameface’s Interaction Manager.

In this tutorial, we’ll build on that by extending the interaction system to support the Team Roster screen, enabling users to navigate and interact with it using a controller or keyboard.

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

From this point forward, each screen we add to the Manager UI will include support for keyboard and gamepad interactions. In this post, we’ll enhance the existing Team Roster screen by:

  • Adding support for spatial navigation between table header and rows
  • Enabling sorting via buttons
  • Supporting drag-and-drop reordering of rows using a gamepad or keyboard
  • Allowing scroll control using the right joystick

General setup

Just like in the previous tutorial, setting up interaction support for a new screen involves two main steps:

  1. Defining the interaction phases
  2. Registering the screen’s navigable areas

We’ll start by extending the interaction phase types to include Team Roster-specific states, and then set up spatial navigation for the new areas.

Adding the interaction phases

First, extend the PHASE enum to include three new states related to the Team Roster screen:

  • TEAM_ROSTER_HEADER - The player has focused the Team Roster screen. We’ll initially place focus on the table header.
  • TEAM_ROSTER_TABLE - The player is navigating the rows of the roster table.
  • TEAM_ROSTER_DRAG - The player has selected a row and entered the drag-and-drop state.
types/types.ts
1
export enum PHASE {
2
SIDE_NAV = "SIDE_NAV",
3
GRID_IDLE = "GRID_IDLE",
4
GRID_DRAG = "GRID_DRAG",
5
TEAM_ROSTER_HEADER = "TEAM_ROSTER_HEADER",
6
TEAM_ROSTER_TABLE = "TEAM_ROSTER_TABLE",
7
TEAM_ROSTER_DRAG = "TEAM_ROSTER_DRAG"
8
}

Registering Navigable Areas

Next, we’ll define two new navigable areas: one for the header and one for the table body. These will allow keyboard and gamepad users to move between the table’s UI elements.

In the onMount function of the TeamRoster component, add the following:

custom-components/Screens/TeamRoster.tsx
1
onMount(() => {
2
areaMappings.push({ area: 'team-roster-header', elements: [`.${styles['Header-Button']}`] })
3
areaMappings.push({ area: 'team-roster-table', elements: [`.${styles.PlayerRow}`] })
4
})

Also update the NavigatableAreaName type to include these new areas:

types/types.ts
1
export type NavigatableAreaName = 'grid' | 'side-nav'
2
export type NavigatableAreaName = 'grid' | 'side-nav' | 'team-roster-header' | 'team-roster-table'

Now that we’ve added new interaction phases and areas, we need to update the handleTabSwitch function to support switching between them.

src/Interactions/index.ts
1
const handleTabSwitch = () => {
12 collapsed lines
2
let areaToFocus: NavigatableAreaName = 'side-nav';
3
switch (gridInteractionState.phase) {
4
case "SIDE_NAV":
5
areaToFocus = 'grid';
6
gridInteractionState.phase = "GRID_IDLE";
7
break;
8
case "GRID_IDLE":
9
areaToFocus = 'side-nav';
10
gridInteractionState.phase = "SIDE_NAV";
11
break;
12
case "GRID_DRAG":
13
return;
14
case PHASE.TEAM_ROSTER_HEADER:
15
areaToFocus = 'team-roster-table';
16
gridInteractionState.phase = PHASE.TEAM_ROSTER_TABLE;
17
break;
18
case PHASE.TEAM_ROSTER_TABLE:
19
areaToFocus = 'team-roster-header';
20
gridInteractionState.phase = PHASE.TEAM_ROSTER_HEADER;
21
break;
22
case PHASE.TEAM_ROSTER_DRAG:
23
return;
24
}
25
26
spatialNavigation.switchArea(areaToFocus);
27
}

Selecting an Inner Screen

When the player confirms a grid item selection in the GRID_DRAG phase, we call the selectInnerScreen function. This function is responsible for transferring focus to the selected screen’s UI and activating any additional behavior.

In the future, this function can handle logic for different screen types. For now, we’ll only implement the behavior for the Team Roster screen.

custom-components/Grid/Grid.tsx
1
const selectInnerScreen = (item: Item) => {
2
switch (item.name) {
3
case "Team Roster":
4
gridInteractionState.phase = PHASE.TEAM_ROSTER_HEADER;
5
spatialNavigation.switchArea('team-roster-header');
6
eventBus.emit('handle-team-roster-scroll', 'init');
7
break;
8
default:
9
// Until other screens are implemeneted -> return to idle
10
handleMouseUp();
11
initSpatialNavigation();
12
gridState.itemRefs.get(gridInteractionState.focusedItem!.id)!.focus();
13
gridInteractionState.phase = PHASE.GRID_IDLE;
14
break;
15
}
16
17
gridInteractionState.dragging = false;
18
}

In this setup:

  • We set the phase to TEAM_ROSTER_HEADER
  • Focus shifts to the header elements via the spatialNavigation
  • We emit a handle-team-roster-scroll event to enable scrolling with the right joystick

Any unimplemented screens will simply cancel the drag and return to the GRID_IDLE state.

Exiting an Inner Screen

To complement the selectInnerScreen logic, we need a way to exit from an inner screen and return to grid drag mode.

We’ll define an exitInnerScreen utility function that:

  • Re-initializes the grid drag state
  • Sets the current phase to GRID_DRAG
  • Optionally runs a cleanup callback (useful for deinitializing things like the joystick scrolling)
custom-components/Grid/Grid.tsx
1
const exitInnerScreen = (fn?: () => void) => {
2
initGridItemDrag(gridInteractionState.focusedItem!);
3
gridInteractionState.phase = PHASE.GRID_DRAG;
4
5
fn && fn();
6
}

This gives us a consistent way to drill out of an inner screen, and allows each screen to define its own cleanup logic if needed.

Interaction flow

Now that we’ve defined the phases and set up entry/exit functions, it’s time to expand the interaction handlers. This includes updating both handleConfirm and handleBack to respond appropriately based on the current phase.

Next we need to determine what the flow of the phases will be and extend the handleConfirm and handleBack handlers. For the confirm handler the flow will be as follows:

Confirm (ENTER / X)

Update handleConfirm in Grid.tsx to include the following logic:

custom-components/Grid/Grid.tsx
1
const handleConfirm = () => {
14 collapsed lines
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 = "GRID_DRAG";
10
break;
11
case PHASE.GRID_DRAG:
12
// Switch to inner screen focus
13
deinitGridItemDrag();
14
selectInnerScreen(gridInteractionState.focusedItem!);
15
break;
16
case PHASE.TEAM_ROSTER_HEADER:
17
eventBus.emit('team-roster-table-sort');
18
break;
19
case PHASE.TEAM_ROSTER_TABLE:
20
initPlayerDrag(gridInteractionState.focusedRow.element!, gridInteractionState.focusedRow.index!)
21
gridInteractionState.phase = PHASE.TEAM_ROSTER_DRAG;
22
break;
23
case PHASE.TEAM_ROSTER_DRAG:
24
deinitPlayerDrag()
25
gridInteractionState.phase = PHASE.TEAM_ROSTER_TABLE;
26
break;
27
default:
28
break;
29
}
30
}

Back (ESC / O)

Similarly, extend the handleBack logic to support backing out of Team Roster phases:

custom-components/Grid/Grid.tsx
1
const handleBack = () => {
11 collapsed lines
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
break;
11
case PHASE.TEAM_ROSTER_HEADER:
12
exitInnerScreen(() => eventBus.emit('handle-team-roster-scroll', 'deinit'))
13
break;
14
case PHASE.TEAM_ROSTER_TABLE:
15
exitInnerScreen(() => eventBus.emit('handle-team-roster-scroll', 'deinit'))
16
break;
17
case PHASE.TEAM_ROSTER_DRAG:
18
cancelPlayerDrag()
19
gridInteractionState.phase = PHASE.TEAM_ROSTER_TABLE;
20
break;
21
default:
22
break;
23
}
24
};

With this setup, each phase now has clear behavior on confirm and back inputs.

Updating Gamepad Button Hints

To maintain consistent feedback for users, we need to update the gamepadMappings object that powers the on-screen button tips.

Add new entries for the Team Roster interaction phases in gamepadMappings.ts that we created in the previous tutorial

Interactions/gamepadMappings.ts
1
export const gamepadMappings: GameMappingsType = {
13 collapsed lines
2
SIDE_NAV: [
3
{ icon: aButton, text: 'Add Item' },
4
{ icon: lb, text: 'Swtich tab' },
5
],
6
GRID_IDLE: [
7
{ icon: aButton, text: 'Select Item' },
8
{ icon: yButton, text: 'Remove Item' },
9
{ icon: lb, text: 'Swtich tab' },
10
],
11
GRID_DRAG: [
12
{ icon: aButton, text: 'Enter screen' },
13
{ icon: bButton, text: 'Back' },
14
{ icon: rb, text: 'Hold to resize' },
15
],
16
TEAM_ROSTER_HEADER: [
17
{ icon: aButton, text: 'Sort Players' },
18
{ icon: bButton, text: 'Back' },
19
{ icon: lb, text: 'Select players' },
20
{ icon: rJoystick, text: 'Scroll Up/Down' },
21
],
22
TEAM_ROSTER_TABLE: [
23
{ icon: aButton, text: 'Select Player' },
24
{ icon: bButton, text: 'Back' },
25
{ icon: lb, text: 'Select header' },
26
{ icon: rJoystick, text: 'Scroll Up/Down' },
27
],
28
TEAM_ROSTER_DRAG: [
29
{ icon: aButton, text: 'Place player' },
30
{ icon: bButton, text: 'Cancel' },
31
{ icon: rJoystick, text: 'Scroll Up/Down' },
32
],
33
}

With the Team Roster phases set up, we can proceed with implementing the logic for these interactions.

Implementing the interactions

Now that the phases and transitions are wired up, let’s implement the actual behavior for each Team Roster interaction: sorting columns, dragging rows, and enabling scroll with the gamepad.

Handling table sort

We’ll start by enabling sorting when focused on the table header.

Step 1: Track the Focused Column

In TableHeader.tsx, add a currentKey signal that stores the currently focused column key.

Screens/TeamRoster/TableHeader.tsx
1
const [currentSort, setCurrentSort] = createSignal<sortType>('ability');
2
const [currentKey, setCurrentKey] = createSignal<sortType>('ability')

Step 2: Listen for Confirm Events

We’ll respond to the global team-roster-table-sort event by calling the sort logic with the current focused key:

Screens/TeamRoster/TableHeader.tsx
1
const handleKeyPress = () => {
2
handleSort(currentKey());
3
};
4
5
onMount(() => {
6
handleSort(currentSort()); // initial sort
7
eventBus.on('team-roster-table-sort', handleKeyPress);
8
});
9
10
onCleanup(() => {
11
eventBus.off('team-roster-table-sort', handleKeyPress);
12
});

Step 3: Add Focus Handling in HeaderColumn

In HeaderColumn.tsx, notify the parent component when the column is focused:

Screens/TeamRoster/HeaderColumn.tsx
9 collapsed lines
1
import Flex from "@components/Layout/Flex/Flex"
2
import { Accessor, createEffect, createSignal, ParentComponent, Setter } from "solid-js"
3
import styles from './TeamRoster.module.css';
4
import {sortType} from '../../../types/types';
5
6
interface Props {
7
currentSort: Accessor<sortType>,
8
asc: Accessor<boolean>,
9
handleSort: (key: sortType) => void,
10
setSortKey: Setter<sortType>
11
sortBy: sortType
12
}
13
14
const HeaderColumn: ParentComponent<Props> = (props) => {
11 collapsed lines
15
const [isActive, setIsActive] = createSignal()
16
17
createEffect(() => {
18
const active = props.currentSort() === props.sortBy;
19
setIsActive(active);
20
});
21
22
const clickHanlder = () => {
23
props.handleSort(props.sortBy)
24
}
25
26
const focusHandler = () => {
27
props.setSortKey(props.sortBy)
28
}
29
30
return (
31
<Flex align-items="center" justify-content="center" class={styles['Header-Button']} click={clickHanlder} focus={focusHandler}>
32
<Flex align-items="center" class={`${isActive() ? styles.ActiveCol : ''} ${props.asc() ? styles.asc: ''}`}>{props.children}</Flex>
33
</Flex>
34
)
35
}
36
37
export default HeaderColumn

Once that’s set up, keyboard/gamepad users can sort the table just by pressing the confirm button on a focused column.

Reordering Player Rows

To support drag-and-drop row reordering with a controller or keyboard, we’ll reuse our existing mouse logic and expose it via event handlers.

The next state we are going to implement is the player’s row drag and drop. Since these event were set up in one of the previous tutorials with data-bind-events and are globaly available, we’d have to take the same approach.

Step 1: Track the Focused Row

In your gridInteractionState, add a focusedRow object:

types/types.ts
1
export interface gridInteractionType {
2
//other properties
3
focusedItem: Item | null,
4
focusedRow: {element: HTMLDivElement | null, index: number | null},
5
phase: PHASE
6
}
store/grindInteractionStore.ts
1
export const gridInteractionState = createMutable<gridInteractionType>({
2
//other properties
3
focusedItem: null,
4
focusedRow: {element: null, index: null},
5
phase: PHASE.SIDE_NAV,
6
});

Step 2: Emit a Focus Event for Rows

utils/global.ts
1
window.playerFocus = (element: HTMLDivElement, index: number) => {
2
gridInteractionState.focusedRow = { element, index };
3
window.playerMouseEnter(element, index);
4
}

Now, update each TableRow to use it:

custom-components/Screens/TeamRoster/TableRow.tsx
1
const TableRow = (props: {sizeExpression: string}) => {
2
return (
3
<Row
4
data-bind-for={'index, player:{{TeamRosterModel.players}}'}
5
class={styles.PlayerRow}
6
data-bind-mousedown="playerMouseDown(this, event, {{index}})"
7
data-bind-mouseenter="playerMouseEnter(this, {{index}})"
8
data-bind-mouseleave="playerMouseLeave(this)"
9
data-bind-focus="playerFocus(this, {{index}})"
10
data-bind-blur="playerMouseLeave(this)"
11
>
12
{/* rest of the component */}

Step 3: Scroll when focusing row out of view

Once we have the focused row’s HTML element, we can easily call the scrollIntoView method of the Scroll component. We’ll add a createEffect to listen for state changes of the focusedRow-meaning that whenever we move the focus to a different row the code will execute.

custom-components/screens/TeamRoster.tsx
1
createEffect(() => {
2
if (gridInteractionState.focusedRow.element === null) return;
3
4
scrollRef.scrollIntoView(gridInteractionState.focusedRow.element as HTMLDivElement);
5
})

Step 4: Create Drag/Drop Utilities

We’ll take some of the swapping logic and put it in separate functions that will take care of initiating and deintiating or canceling out of the dragging state. To not populate the global.ts file, we’ll create a separate team-roster-utils.ts file and put this logic there.

utils/team-roster-utils.ts
1
import { focusNext } from "../Interactions";
2
import { updateModel } from ".";
3
import { gridInteractionState } from "../store/gridInteractionStore";
4
5
export const playerDragState = {
6
startMouseX: 0,
7
startMouseY: 0,
8
startLeft: 0,
9
startTop: 0,
10
startIndex: 0,
11
dropIndex: 0,
12
draggedRowRef: null as HTMLDivElement | null,
13
};
14
15
export const initPlayerDrag = (element: HTMLDivElement, index: number) => {
16
const { width, height, left, top } = element.getBoundingClientRect();
17
// clone html element
18
gridInteractionState.draggedRow = element.cloneNode(true) as HTMLDivElement;
19
const draggedElement = gridInteractionState.draggedRow;
20
21
// set state
22
playerDragState.startIndex = index;
23
playerDragState.draggedRowRef = element;
24
25
Object.assign(draggedElement.style, {
26
width: `${width + 10}px`,
27
height: `${height}px`,
28
left: `${left}px`,
29
top: `${top}px`,
30
color: 'white',
31
});
32
33
// hide original element
34
element.classList.add('hidden');
35
focusNext();
36
// attach dragging class on the player
37
draggedElement.classList.add('player-row-dragging');
38
39
return draggedElement;
40
}
41
42
export const deinitPlayerDrag = () => {
43
let { startIndex, dropIndex } = playerDragState;
44
// Reset
45
cancelPlayerDrag();
46
// Swap rows
47
const playersArr = TeamRosterModel.players;
48
if (startIndex < dropIndex) dropIndex--;
49
const [elToSwap] = playersArr.splice(startIndex, 1);
50
playersArr.splice(dropIndex, 0, elToSwap)
51
52
updateModel(TeamRosterModel)
53
}
54
55
export const cancelPlayerDrag = () => {
56
let { dropIndex, draggedRowRef } = playerDragState;
57
// Reset
58
gridInteractionState.draggedRow = null;
59
draggedRowRef!.classList.remove('hidden');
60
(draggedRowRef!.parentElement?.children[dropIndex] as HTMLDivElement).classList.remove('new-row-position');
61
}
Bonus: Since we are reusing a lot of the logic, we can simplify the mouse logic we set up in the previous Team Roster screen tutorial .
utils/global.ts
1
import { updatePositionStyles } from "./index";
2
import { gridInteractionState } from "../store/gridInteractionStore";
3
import { deinitPlayerDrag, initPlayerDrag, playerDragState } from "./team-roster-utils";
4
5
let startMouseX = 0;
6
let startMouseY = 0;
7
let startLeft = 0;
8
let startTop = 0;
9
let dropIndex = 0;
10
11
window.playerMouseDown = (element, event, index) => {
12
// disable grid interactions
13
gridInteractionState.shouldDrag = false;
14
15
const { clientWidth, clientHeight } = element;
16
17
// clone html element
18
gridInteractionState.draggedRow = element.cloneNode(true) as HTMLDivElement;
19
const draggedElement = gridInteractionState.draggedRow;
20
21
Object.assign(draggedElement.style, {
22
width: `${clientWidth}px`,
23
height: `${clientHeight}px`,
24
color: 'white',
25
});
26
27
// hide original element
28
element.classList.add('hidden')
29
30
// get starting mouse coordinates
31
startMouseX = event.clientX;
32
startMouseY = event.clientY;
33
34
// get initial element position
35
startLeft = startMouseX - (element.clientWidth / 2);
36
startTop = startMouseY - (element.clientHeight / 2);
37
38
updatePositionStyles(draggedElement, startLeft, startTop);
39
40
// attach dragging class on the player
41
draggedElement.classList.add('player-row-dragging');
42
43
const draggedElement = initPlayerDrag(element, index);
44
45
// get starting mouse coordinates
46
playerDragState.startMouseX = event.clientX;
47
playerDragState.startMouseY = event.clientY;
48
// get initial element position
49
playerDragState.startLeft = playerDragState.startMouseX - (element.clientWidth / 2);
50
playerDragState.startTop = playerDragState.startMouseY - (element.clientHeight / 2);
51
52
const mouseMoveHandler = (e: MouseEvent) => window.playerMouseMove(draggedElement, e);
53
const mouseUpHandler = () => {
54
window.playerMouseUp(element, index);
55
window.removeEventListener("mousemove", mouseMoveHandler);
56
window.removeEventListener("mouseup", mouseUpHandler)
57
}
58
59
// attach listeners for move and up
60
window.addEventListener("mousemove", mouseMoveHandler);
61
window.addEventListener("mouseup", mouseUpHandler);
62
}
63
64
window.playerMouseUp = (element, index) => {
65
window.playerMouseUp = () => {
66
gridInteractionState.shouldDrag = true;
67
68
// Reset
69
gridInteractionState.draggedRow = null
70
element!.classList.remove('hidden');
71
(element.parentElement?.children[dropIndex] as HTMLDivElement).classList.remove('new-row-position');
72
73
// Swap rows
74
const playersArr = TeamRosterModel.players;
75
if (index < dropIndex) dropIndex--;
76
const [elToSwap] = playersArr.splice(index, 1);
77
playersArr.splice(dropIndex, 0, elToSwap)
78
79
updateModel(TeamRosterModel)
80
deinitPlayerDrag();
81
}

Once wired in, confirm places the row, and back cancels it.

Scrolling with the Right Joystick

The last interaction for the Team Roster screen is to enable the Scroll component to be scrolled with the right joystick. We want to initialize this behaviour when the selected inner screen is the Team Roster and deinit it when the player drills out of the screen.

We are alreay emitting the handle-team-roster-scroll event in our confirm/back handlers, now we need to listen for it in the TeamRoster component. When we emit events with the eventBus we can pass arguments to the event and use them in the handlers. In this case we are passing a status string which will init or deinit the behavior depending on the interaction phase.

custom-components/screens/TeamRoster.tsx
1
// rest of the component
2
const handleGamepadScroll = (status: 'init' | 'deinit') => {
3
if (status === 'init') useGamepadScroll(
4
() => scrollRef.scrollUp(),
5
() => scrollRef.scrollDown()
6
);
7
else deinitGamepadScroll();
8
}
9
10
onMount(() => {
11
areaMappings.push({ area: 'team-roster-header', elements: [`.${styles['Header-Button']}`] })
12
areaMappings.push({ area: 'team-roster-table', elements: [`.${styles.PlayerRow}`] })
13
14
eventBus.on('handle-team-roster-scroll', handleGamepadScroll)
15
})
16
17
onCleanup(() => {
18
eventBus.off('handle-team-roster-scroll', handleGamepadScroll)
19
})
20
21
// rest of the component

We also need to add the useGamepadScroll and deinitGamepadScroll hooks. We can put them in the Interactions/index.ts directory.

src/Interactions/index.ts
1
export const useGamepadScroll = (scrollUp: () => void, scrollDown: () => void) => {
2
gamepad.on({
3
actions: ['right.joystick.down'],
4
callback: scrollDown,
5
});
6
7
gamepad.on({
8
actions: ['right.joystick.up'],
9
callback: scrollUp,
10
})
11
}
12
13
export const deinitGamepadScroll = () => {
14
gamepad.off(['right.joystick.down']);
15
gamepad.off(['right.joystick.up']);
16
}

Conclusion

The Team Roster screen is now fully navigable and interactive using keyboard and gamepad input. Players can:

  • Sort the table by column
  • Toggle focus between the table header and rows
  • Drag and reorder player rows
  • Scroll the table using the right joystick

In the upcoming tutorials, we’ll continue expanding this system by adding support for additional screens—ensuring a consistent and polished input experience across the entire UI.

On this page