Manager UI (part 5): Team Roster - Keyboard and gamepad navigation
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:
- Defining the interaction phases
- 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.
1export 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:
1onMount(() => {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:
1export type NavigatableAreaName = 'grid' | 'side-nav'2export 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.
1const 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.
1const 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 idle10 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)
1const 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:
1const 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 mode8 initGridItemDrag(gridInteractionState.focusedItem!);9 gridInteractionState.phase = "GRID_DRAG";10 break;11 case PHASE.GRID_DRAG:12 // Switch to inner screen focus13 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:
1const 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 focus8 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
1export 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.
1const [currentSort, setCurrentSort] = createSignal<sortType>('ability');2const [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:
1const handleKeyPress = () => {2 handleSort(currentKey());3};4
5onMount(() => {6 handleSort(currentSort()); // initial sort7 eventBus.on('team-roster-table-sort', handleKeyPress);8});9
10onCleanup(() => {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:
9 collapsed lines
1import Flex from "@components/Layout/Flex/Flex"2import { Accessor, createEffect, createSignal, ParentComponent, Setter } from "solid-js"3import styles from './TeamRoster.module.css';4import {sortType} from '../../../types/types';5
6interface Props {7 currentSort: Accessor<sortType>,8 asc: Accessor<boolean>,9 handleSort: (key: sortType) => void,10 setSortKey: Setter<sortType>11 sortBy: sortType12}13
14const 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
37export 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:
1export interface gridInteractionType {2 //other properties3 focusedItem: Item | null,4 focusedRow: {element: HTMLDivElement | null, index: number | null},5 phase: PHASE6}
1export const gridInteractionState = createMutable<gridInteractionType>({2 //other properties3 focusedItem: null,4 focusedRow: {element: null, index: null},5 phase: PHASE.SIDE_NAV,6});
Step 2: Emit a Focus Event for Rows
1window.playerFocus = (element: HTMLDivElement, index: number) => {2 gridInteractionState.focusedRow = { element, index };3 window.playerMouseEnter(element, index);4}
Now, update each TableRow to use it:
1const TableRow = (props: {sizeExpression: string}) => {2 return (3 <Row4 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.
1createEffect(() => {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.
1import { focusNext } from "../Interactions";2import { updateModel } from ".";3import { gridInteractionState } from "../store/gridInteractionStore";4
5export 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
15export const initPlayerDrag = (element: HTMLDivElement, index: number) => {16 const { width, height, left, top } = element.getBoundingClientRect();17 // clone html element18 gridInteractionState.draggedRow = element.cloneNode(true) as HTMLDivElement;19 const draggedElement = gridInteractionState.draggedRow;20
21 // set state22 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 element34 element.classList.add('hidden');35 focusNext();36 // attach dragging class on the player37 draggedElement.classList.add('player-row-dragging');38
39 return draggedElement;40}41
42export const deinitPlayerDrag = () => {43 let { startIndex, dropIndex } = playerDragState;44 // Reset45 cancelPlayerDrag();46 // Swap rows47 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
55export const cancelPlayerDrag = () => {56 let { dropIndex, draggedRowRef } = playerDragState;57 // Reset58 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 .
1import { updatePositionStyles } from "./index";2import { gridInteractionState } from "../store/gridInteractionStore";3import { deinitPlayerDrag, initPlayerDrag, playerDragState } from "./team-roster-utils";4
5let startMouseX = 0;6let startMouseY = 0;7let startLeft = 0;8let startTop = 0;9let dropIndex = 0;10
11window.playerMouseDown = (element, event, index) => {12 // disable grid interactions13 gridInteractionState.shouldDrag = false;14
15 const { clientWidth, clientHeight } = element;16
17 // clone html element18 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 element28 element.classList.add('hidden')29
30 // get starting mouse coordinates31 startMouseX = event.clientX;32 startMouseY = event.clientY;33
34 // get initial element position35 startLeft = startMouseX - (element.clientWidth / 2);36 startTop = startMouseY - (element.clientHeight / 2);37
38 updatePositionStyles(draggedElement, startLeft, startTop);39
40 // attach dragging class on the player41 draggedElement.classList.add('player-row-dragging');42
43 const draggedElement = initPlayerDrag(element, index);44
45 // get starting mouse coordinates46 playerDragState.startMouseX = event.clientX;47 playerDragState.startMouseY = event.clientY;48 // get initial element position49 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 up60 window.addEventListener("mousemove", mouseMoveHandler);61 window.addEventListener("mouseup", mouseUpHandler);62}63
64window.playerMouseUp = (element, index) => {65window.playerMouseUp = () => {66 gridInteractionState.shouldDrag = true;67
68 // Reset69 gridInteractionState.draggedRow = null70 element!.classList.remove('hidden');71 (element.parentElement?.children[dropIndex] as HTMLDivElement).classList.remove('new-row-position');72
73 // Swap rows74 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.
1// rest of the component2const handleGamepadScroll = (status: 'init' | 'deinit') => {3 if (status === 'init') useGamepadScroll(4 () => scrollRef.scrollUp(),5 () => scrollRef.scrollDown()6 );7 else deinitGamepadScroll();8}9
10onMount(() => {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
17onCleanup(() => {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.
1export 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
13export 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.