Manager UI (part 1): Creating a draggable grid in Gameface

ui tutorials

3/7/2025

Martin Bozhilov

In this tutorial series we will create a Football simulator game. The game will allow you to take control of a team and be able to rearrange the players, set the tactics, see statistics and more. The base of the game will be a grid where the player will be able to rearrange the game screens and resize them.

We’ve found other manager games having a lot of information in their UI that doesn’t have good accessibility. Using Coherent Gameface we can show you that you are able to have a modular UI that is easy to change and rearrange, mitigating one of the main issues many games of this type have.

Prerequisites

In this tutorial, we will use the following technologies:

  • Gameface UI - A set of UI components for rapid UI game development and prototyping (Comming soon).
  • Solid JS.
  • TypeScript.

Overview

We will create a draggable grid component that will allow us to rearrange the game screens.

The grid will have the following features:

  • Drag and drop grid items
  • Swap grid items
  • Resize grid items
  • Load and save grid state (item positions and sizes)

Source location

Once the whole UI is completed, you will be able to find the complete sample source within the ${Gameface package}/Samples/uiresources/UITutorials/ManagerUI directory.

Project setup

To initialize our project, we will follow the guide in the Gameface UI documentation. Since Gameface UI uses SolidJS and TypeScript, they will come bundled with the project.

Once we have our project set up, we can start creating our UI in the HUD component.

Grid component

The grid component will be the core interaction component of the UI. We will begin by creating a Grid.tsx and Grid.module.css files in the custom-components directory.

/custom-components/Grid/Grid.tsx
1
import { Component } from "solid-js";
2
import styles from './Grid.module.css';
3
4
const Grid: Component = () => {
5
return (
6
<div class={styles.Grid}></div>
7
)
8
}

And import it in the Hud.tsx file.

/views/hud/Hud.tsx
1
import styles from './Hud.module.css';
2
import Layout from '@components/Layout/Layout/Layout';
3
import Grid from '@custom-components/Grid/Grid';
4
import Row from '@components/Layout/Row/Row';
5
import { Column11, Column1 } from '@components/Layout/Column/Column';
6
7
const Hud = () => {
8
return (
9
<Layout>
10
<Row class={styles.Hud} >
11
<Column1></Column1>
12
<Column11>
13
<Grid />
14
</Column11>
15
</Row>
16
</Layout>
17
);
18
};
19
20
export default Hud;

The Layout, Row, and Column components are part of the Gameface UI library and are used to setup a layout without the need of any CSS.

Setting grid model

Before adding any logic to our grid, we need to define the state of the grid. We are going to utilize a model object to mock game data. Our model will contain a gridItems array containing objects with the following properties:

  • id - a unique identifier for the grid item
  • name - the name of the grid item
  • x - the x position of the grid item
  • y - the y position of the grid item
  • col - the starting column of the grid item
  • row - the starting row of the grid item
  • sizeX - the width of the grid item
  • sizeY - the height of the grid item
  • minSizeX - the minimum width of the grid item
  • minSizeY - the minimum height of the grid item
  • maxSizeX - the maximum width of the grid item
  • maxSizeY - the maximum height of the grid item
  • active - a boolean indicating if the grid item is active and should be displayed
/models/grid-data-model.js
1
const GridDataModel = {
2
gridItems: [
3
{
4
id: 1,
5
name: "Team Roster",
6
x: 0,
7
y: 0,
8
col: 0,
9
row: 0,
10
sizeX: 1,
11
sizeY: 1,
12
minSizeX: 1,
13
minSizeY: 1,
14
maxSizeX: 3,
15
maxSizeY: 3,
16
active: true,
17
},
18
{
19
id: 2,
20
name: "Match Tactics",
21
x: 0,
22
y: 0,
23
col: 1,
24
row: 1,
25
sizeX: 2,
26
sizeY: 2,
27
minSizeX: 2,
28
minSizeY: 1,
29
maxSizeX: 3,
30
maxSizeY: 3,
31
active: true,
152 collapsed lines
32
},
33
{
34
id: 3,
35
name: "Team Settings",
36
x: 0,
37
y: 0,
38
col: 2,
39
row: 0,
40
sizeX: 1,
41
sizeY: 1,
42
minSizeX: 1,
43
minSizeY: 1,
44
maxSizeX: 3,
45
maxSizeY: 3,
46
active: true,
47
},
48
{
49
id: 4,
50
name: "Player Stats",
51
x: 0,
52
y: 0,
53
col: 0,
54
row: 0,
55
sizeX: 1,
56
sizeY: 2,
57
minSizeX: 1,
58
minSizeY: 1,
59
maxSizeX: 3,
60
maxSizeY: 3,
61
active: false,
62
},
63
{
64
id: 5,
65
name: "Match Calendar",
66
x: 0,
67
y: 0,
68
col: 0,
69
row: 0,
70
sizeX: 1,
71
sizeY: 2,
72
minSizeX: 1,
73
minSizeY: 1,
74
maxSizeX: 3,
75
maxSizeY: 3,
76
active: false,
77
},
78
{
79
id: 6,
80
name: "Team Morale",
81
x: 0,
82
y: 0,
83
col: 0,
84
row: 0,
85
sizeX: 1,
86
sizeY: 2,
87
minSizeX: 1,
88
minSizeY: 1,
89
maxSizeX: 3,
90
maxSizeY: 3,
91
active: false,
92
},
93
{
94
id: 7,
95
name: "Financials",
96
x: 0,
97
y: 0,
98
col: 0,
99
row: 0,
100
sizeX: 2,
101
sizeY: 1,
102
minSizeX: 1,
103
minSizeY: 1,
104
maxSizeX: 3,
105
maxSizeY: 3,
106
active: false,
107
},
108
{
109
id: 8,
110
name: "Transfer Market",
111
x: 0,
112
y: 0,
113
col: 0,
114
row: 0,
115
sizeX: 3,
116
sizeY: 2,
117
minSizeX: 1,
118
minSizeY: 1,
119
maxSizeX: 3,
120
maxSizeY: 3,
121
active: false,
122
},
123
{
124
id: 9,
125
name: "Match Stats",
126
x: 0,
127
y: 0,
128
col: 0,
129
row: 0,
130
sizeX: 1,
131
sizeY: 3,
132
minSizeX: 1,
133
minSizeY: 1,
134
maxSizeX: 3,
135
maxSizeY: 3,
136
active: false,
137
},
138
{
139
id: 10,
140
name: "Team News",
141
x: 0,
142
y: 0,
143
col: 0,
144
row: 0,
145
sizeX: 2,
146
sizeY: 3,
147
minSizeX: 1,
148
minSizeY: 1,
149
maxSizeX: 3,
150
maxSizeY: 3,
151
active: false,
152
},
153
{
154
id: 11,
155
name: "Scouting",
156
x: 0,
157
y: 0,
158
col: 0,
159
row: 0,
160
sizeX: 3,
161
sizeY: 1,
162
minSizeX: 1,
163
minSizeY: 1,
164
maxSizeX: 3,
165
maxSizeY: 3,
166
active: false,
167
},
168
{
169
id: 12,
170
name: "Manager Stats",
171
x: 0,
172
y: 0,
173
col: 0,
174
row: 0,
175
sizeX: 2,
176
sizeY: 2,
177
minSizeX: 1,
178
minSizeY: 1,
179
maxSizeX: 3,
180
maxSizeY: 3,
181
active: false,
182
},
183
];
184
}
185
186
export default GridDataModel;

Storing the grid’s information in a model will allow us to preserve the state of the grid. In a real world scenario where the model is not mocked this will allow you to save the player’s ordering of the grid.

Next up, we need to create the model in the engine.whenReady event. We will create an index.js file in the models directory where we are going to initialize all of our models.

/models/index.js
1
import GridDataModel from './grid-data-model';
2
3
engine.whenReady.then(() => {
4
engine.createJSModel('GridData', GridDataModel);
5
engine.synchronizeModels();
6
});

After this in the index.tsx in the hud folder we need to import cohtml.js (make sure to include it in your project) and the models. We also need to make sure to wrap the render function in the engine.whenReady event to make sure all our models are loaded before we start rendering the UI.

views/hud/index.tsx
1
import { render } from 'solid-js/web';
2
import './index.css';
3
import Hud from './Hud';
4
import '../../cohtml.js';
5
import '../../models/'
6
7
const root = document.getElementById('root');
8
9
engine.whenReady.then(() => {
10
render(() => <Hud />, root!);
11
});

Creating a store to hold the grid state

To manage the grid state, we will create a SolidJS store that will hold the grid items array and an additional itemRefs Map object that will hold a reference to the html element of each grid item.

Let’s initialize our state by create a GridStore.ts file in the stores directory.

/stores/GridStore.ts
1
import { createStore, produce } from "solid-js/store";
2
import type { gridState, Item, Position } from "../types/types";
3
import styles from '@custom-components/Grid/Grid.module.css'
4
5
const MAXROWS = 3;
6
const MAXCOLS = 5;
7
8
const [gridState, setGridState] = createStore<gridState>({
9
itemRefs: new Map<number, HTMLElement>(),
10
items: []
11
});

Initially, we will have an empty array of items and an empty Map object. Also, we defined the MAXROWS and MAXCOLS constants that will be used to limit the grid size.

Now in the Hud.tsx we need to populate our store with the data from the GridData model.

/views/hud/Hud.tsx
1
import { setGridState } from '../../store/gridStore';
2
3
setGridState('items', GridData.gridItems)
4
5
const Hud = () => {

With that out of the way, we now can start setting up the logic of the grid component.

You can refer to the last section for the typescript types

Populating the grid with cells

To create the grid, we will use a 2D array of GridCell objects. Each cell will have a reference to the html element and a property occupiedBy that will hold the id of the grid item that occupies the cell. We will be using the html element of the cell to get their DOM top and left position and assign them to the grid items.

Let’s define the cells in the gridStore.ts file.

/stores/GridStore.ts
1
const cells = initGridCells(MAXROWS, MAXCOLS)

We will create a helper function initGridCells that will initialize the 2D array of cells.

/utils/index.ts
1
import { GridCell } from "../types/types";
2
3
export const initGridCells = (maxRows: number, maxCols: number) => {
4
const cells: GridCell[][] = [];
5
6
for (let row = 0; row < maxRows; row++) {
7
const rowCells: GridCell[] = []
8
for (let col = 0; col < maxCols; col++) {
9
rowCells.push({ occupiedBy: undefined, element: undefined });
10
}
11
cells.push(rowCells);
12
}
13
14
return cells;
15
}

Now, we can import the cells 2D array in the Grid component and render all the cells.

Grid.tsx
1
import { Component, onMount, onCleanup, For, createMemo } from "solid-js";
2
import styles from './Grid.module.css'
3
import { Item, GridCell, Position } from "../../../types/types";
4
import { gridState, removeItem, setGridState, isCellBlockValid, clearCell, occupyCell, MAXCOLS, MAXROWS, cells, updateModel } from "../../store/gridStore";
5
6
const Grid: Component = () => {
7
let gridRef!: HTMLDivElement;
8
9
const initCell = (cell: GridCell) => {
10
return (
11
<div
12
ref={el => cell.element = el}
13
class={styles.Cell}>
14
</div>
15
)
16
}
17
18
return (
19
<div ref={gridRef} class={styles.Grid} >
20
{cells.flat().map(cell => initCell(cell))}
21
</div>
22
);
23
};
24
25
export default Grid;

Here we loop through all items in the cells array and call the helper function initCell which will render a div element for each cell and assign the ref to the element property.

Let’s add some styles to visualize the grid.

Grid.module.css
1
.Grid {
2
width: 100%;
3
height: 100%;
4
display: flex;
5
padding: 1%;
6
flex-wrap: wrap;
7
position: relative;
8
}
9
10
.Cell {
11
width: 18%;
12
height: 30%;
13
margin: 1%;
14
border: 1px solid white; /* Add a border to visualize the grid cells */
15
}

The end result should look like this:

Grid with cells

Displaying grid items on the grid

The next step is to populate the grid with items. Each item will consist of:

  • A wrapper div element that will be placed absolutely and will have the top and left position set to the item’s y and x properties.
  • A predefined size class that will set the width and height of the item.
  • A resize handle that will allow the user to resize the item.
  • A close Icon that will allow the user to remove the item from the grid.

Let’s begin by defining the size and icon styles

Grid.module.css
1
.CellItem {
2
background-color: #004b8c; /* placeholder color for visualization */
3
position: absolute;
4
transition: opacity 0.5s ease-in-out, left 0.25s ease-in-out, top 0.25s ease-in-out;
5
}
6
7
.Active {
8
opacity: 1;
9
}
10
11
.Inactive {
12
opacity: 0;
13
pointer-events: none;
14
}
15
16
.CellItem-drag {
17
opacity: 0;
18
border: 10px solid white;
19
border-left-color: transparent;
20
border-top-color: transparent;
21
position: absolute;
22
right: 3px;
23
bottom: 3px;
24
transition: opacity 0.2s ease-in-out;
25
}
26
27
.CellItem-close {
28
opacity: 0;
29
position: absolute;
30
right: 3px;
31
top: 3px;
32
padding: 0.25vmax 0.5vmax;
33
cursor: pointer;
34
color: white;
35
font-weight: bold;
36
transition: opacity 0.2s ease-in-out;
37
}
38
39
.CloseIcon {
40
transform: scaleX(1.3);
41
}
42
43
.CellItem-drag:hover,
44
.CellItem-close:hover {
45
opacity: 1;
46
}
47
48
.Dragging {
49
opacity: 0.8;
50
z-index: 999999;
51
box-shadow: 0 2px 6px 1px rgba(0,0,0,0.5);
52
transition: none;
53
}
54
55
.Item-1x1 {
56
width: 18%;
57
height: 30%;
58
}
59
39 collapsed lines
60
.Item-1x2 {
61
width: 18%;
62
height: 63.5%;
63
}
64
65
.Item-1x3 {
66
width: 18%;
67
height: 97%;
68
}
69
70
.Item-2x1 {
71
width: 38%;
72
height: 30%;
73
}
74
75
.Item-2x2 {
76
width: 38%;
77
height: 63.5%;
78
}
79
80
.Item-2x3 {
81
width: 38%;
82
height: 97%;
83
}
84
85
.Item-3x1 {
86
width: 58%;
87
height: 30%;
88
}
89
90
.Item-3x2 {
91
width: 58%;
92
height: 63.5%;
93
}
94
95
.Item-3x3 {
96
width: 58%;
97
height: 97%;
98
}

We can now loop through the items in the gridState store and render the element for each item. We are going to use the Active class to show only the items marked as active.

Grid.tsx
1
return (
2
<div ref={gridRef} class={styles.Grid} >
3
{cells.flat().map(cell => initCell(cell))}
4
5
<For each={gridState.items} >
6
{(item) => {
7
return (
8
<div
9
ref={(el) => gridState.itemRefs.set(item.id, el)}
10
style={{
11
top: `${item.y}px`,
12
left: `${item.x}px`,
13
}}
14
class={`${styles[`Item-${item.sizeX}x${item.sizeY}`]} ${styles.CellItem} ${item.active ? styles.Active : styles.Inactive}`}>
15
<div class={styles['CellItem-drag']} ></div>
16
<div class={styles['CellItem-close']}>
17
<div class={styles.CloseIcon}>X</div>
18
</div>
19
</div>
20
)
21
}}
22
</For>
23
</div>
24
);

One final thing to do is to add an initItems function which will:

  • set the x and y properties of the items based on the cell position (row and col) they are occupying in the grid.
  • Mark the cell the item is placed at as occupied.
Grid.tsx
1
const initItems = () => {
2
setGridState('items', produce((items) => {
3
items.forEach((item) => {
4
item.x = cells[item.row][item.col].element?.offsetLeft!;
5
item.y = cells[item.row][item.col].element?.offsetTop!;
6
})
7
}));
8
9
gridState.items.forEach((item) => {
10
if (item.active) {
11
occupyCell(item.row, item.col, item)
12
}
13
})
14
}
15
16
onMount(() => {
17
delayExecution(() => {
18
const firstCellElement = cells[0][0].element;
19
if (firstCellElement) {
20
initItems();
21
}
22
})
23
});

We do this in the onMount lifecycle event and wrap all the logic with the delayExecution helper function to make sure that the model has loaded. The delayExecution function will allow us to wait 3 frames before doing anything with the model’s data.

utils/index.ts
1
export const delayExecution = (callback: () => void, delay: number = 3) => {
2
if (delay === 0) {
3
callback();
4
return;
5
}
6
7
requestAnimationFrame(() => {
8
delayExecution(callback, delay - 1);
9
});
10
}

Now let’s implement the occupyCell function in the gridStore.ts file.

GridStore.ts
1
const occupyCell = (newRow: number, newCol: number, item: Item) => {
2
for (let row = 0; row < item.sizeY; row++) {
3
for (let col = 0; col < item.sizeX; col++) {
4
const cell = cells[newRow + row][newCol + col];
5
cell.occupiedBy = item.id;
6
}
7
}
8
}

The occupyCell function will loop through the cells that the item is occupying and set the occupiedBy property to the item’s id.

Now if you save the changes you should see your grid populated with items.

Grid with items

Dragging grid items

With our grid cells in place, we need to let users click and drag an item around. The core idea is to track:

  1. Which item is being dragged - dragging
  2. Whether we are currently dragging an item - draggedItem
  3. The initial position of the mouse when the drag starts to calculate the offset from the top left corner of the item - dropPosition

First we need to get the width and height of the cells as well as the gap between them. We will use these values to calculate the current row and col of the dragged item.

We’ll create a separate store that will contain the drag state of the grid, and three event handlers— handleMouseDown, handleMouseMove, and handleMouseUp to track the mouse from press, through movement, to release.

Additionally, we will define helper variables to store the initial mouse position and the offset from the top left corner of the item.

Finally, we will add a DropCell component which will be used to visualize the drop position of the item while dragging.

Let’s start by defining the helper variables.

Grid.tsx
1
const DRAG_THRESHOLD: number = 5;
2
let startLeft: number = 0,
3
startTop: number = 0,
4
startMouseX: number = 0,
5
startMouseY: number = 0,
6
offsetX: number = 0,
7
offsetY: number = 0;
8
9
const Grid: Component = () => {
10
let cellWidth: number;
11
let cellHeight: number;
12
let gapX: number;
13
let gapY: number;

Now let’s implement the interactions store:

store/gridInteractionStore.ts
1
import { createMutable, modifyMutable, produce } from "solid-js/store";
2
import { gridInteractionType, Item, Position } from "src/types/types";
3
4
export const gridInteractionState = createMutable<gridInteractionType>({
5
dragging: false,
6
draggedItem: null as Item | null,
7
dropPosition: null as Position | null,
8
shouldDrag: true,
9
});

Notice how for this store we are using Solid’s createMutable function. The reason we do this is to ensure that we’ll trigger a state update even if objects such as dropPosition change. Because if done with a normal createStore, updating the properties of the object won’t trigger a state update. Another benefit that comes from this is that we don’t need a setter function with createMutable and we can update properties like we normally do.

With that out of the way, the last thing to do is to get the cells width and height and the gap between them in the onMount event. As well as add the event handlers.

Grid.tsx
1
onMount(() => {
2
delayExecution(() => {
3
const firstCellElement = cells[0][0].element;
4
if (firstCellElement) {
5
cellWidth = firstCellElement.clientWidth;
6
cellHeight = firstCellElement.clientHeight;
7
gapX = firstCellElement.offsetLeft;
8
gapY = firstCellElement.offsetTop;
9
initItems();
10
}
11
})
12
13
window.addEventListener("mousemove", handleMouseMove);
14
window.addEventListener("mouseup", handleMouseUp);
15
});

And remove the event handlers in the onCleanup event.

Grid.tsx
1
onCleanup(() => {
2
window.removeEventListener("mousemove", handleMouseMove);
3
window.removeEventListener("mouseup", handleMouseUp);
4
});

Now let’s define the event handlers.

Mouse down event

On the mousedown event we are going to check if the user clicked on an item and if so we are going to set the dragging state to true and store the initial mouse position and the offset from the top left corner of the item.

This way we will keep the item’s position relative to where the user clicked.

Grid.tsx
1
const startDragging = (item: Item) => {
2
startLeft = item.x;
3
startTop = item.y;
4
offsetX = startMouseX - startLeft;
5
offsetY = startMouseY - startTop;
6
gridInteractionState.dragging = true;
7
}
8
9
// Start dragging when mousedown occurs on an item
10
const handleMouseDown = (e: MouseEvent, item: Item) => {
11
if (!gridRef || !gridInteractionState.shouldDrag) return;
12
13
gridInteractionState.draggedItem = item
14
const currentItemElement = gridState.itemRefs.get(item.id)
15
16
// Store the initial mouse position
17
startMouseX = e.clientX;
18
startMouseY = e.clientY;
19
20
const clickedElement = e.target as HTMLElement;
21
if (clickedElement.closest(`.${styles.CellItem}`)) startDragging(item)
22
23
e.preventDefault();
24
};

One thing to note is the flag shouldDrag we defined in our store earlier. This flag enables us to have control from the outside over whether an interaction with the grid should trigger. This will be useful to have in the future if we want to have interactions inside the components of the grid items.

Mouse move event

On the mousemove event we are going to:

  • Calculate the new position of the item based on the mouse position and the offset from the top left corner of the item.
  • Get the row and col the item is currently hovering on.
  • Check if the new row and col are valid and if not we are going to return the item to its previous position.

Additionally we will attach a dragging class to visualize the interaction and set a safety check and check if the mouse has moved more than the DRAG_THRESHOLD. This is done to prevent accidental drags if the user holds their mouse and drags a little.

Grid.tsx
1
// Update drop target based on mouse position
2
const handleMouseMove = (e: MouseEvent) => {
3
if (!gridInteractionState.draggedItem || !gridRef) return;
4
5
const dx = Math.abs(e.clientX - startMouseX);
6
const dy = Math.abs(e.clientY - startMouseY);
7
// Only trigger actions if the pointer has moved enough
8
if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return;
9
10
const mouseX = e.clientX
11
const mouseY = e.clientY
12
13
const itemleft = mouseX - offsetX;
14
const itemTop = mouseY - offsetY;
15
16
const currentItem = gridInteractionState.draggedItem;
17
18
gridState.itemRefs.get(currentItem!.id)?.classList.add(styles.Dragging)
19
// Pass them as fallback to return to prev place
20
const currentItemRow = currentItem?.row
21
const currentItemCol = currentItem?.col
22
23
if (gridInteractionState.dragging && currentItem) {
24
const { col, row } = getGridPosition(itemleft, itemTop, MAXCOLS, MAXROWS)
25
26
changeItemPosition(currentItem!, mouseX, mouseY);
27
28
const prevPosition = gridInteractionState.dropPosition;
29
if (!prevPosition || row !== prevPosition.row || col !== prevPosition.col) {
30
gridInteractionState.dropPosition = {col, row}
31
}
32
33
if (!memoizedCellValidation()) {
34
gridInteractionState.dropPosition = { col: currentItemCol, row: currentItemRow } // fallback
35
}
36
}
37
};

To achieve the following we will need a couple of helper functions.

  • getGridPosition - calculates the row and col based on the mouse position and the grid cells.
  • changeItemPosition - continuously updates the item’s x and y based on the current mouse position.
  • memoizedCellValidation - checks if the new position of the item is valid. It triggers only when the row or col changes decreasing the executions of the function and improving performance.
Grid.tsx
1
function getGridPosition(itemLeft: number, itemTop: number, maxCol: number, maxRow: number) {
2
const adjustedColWidth = cellWidth + gapX;
3
const adjustedRowHeight = cellHeight + gapY;
4
5
return {
6
col: clamp(Math.floor((itemLeft - (gapX / 2)) / adjustedColWidth), maxCol, 0),
7
row: clamp(Math.floor((itemTop - (gapY / 2)) / adjustedRowHeight), maxRow, 0)
8
};
9
}

As you can notice, we are using a clamp function to limit the row and col values between the max grid size or 0. This will ensure that the item will not go out of bounds. Let’s define the clamp function in our ‘utils’ folder.

utils/index.ts
1
export const clamp = (value: number, maxValue: number, minValue: number) => {
2
return Math.max(minValue, Math.min(value, maxValue))
3
}

Now to update the item’s position:

Grid.tsx
1
const changeItemPosition = (item: Item, mouseX: number, mouseY: number) => {
2
if (item) {
3
// Compute the new position while keeping the original offset
4
const deltaX = mouseX - startMouseX;
5
const deltaY = mouseY - startMouseY;
6
7
// New position should maintain the original top-left corner offset
8
const newLeft = startLeft + deltaX;
9
const newTop = startTop + deltaY;
10
11
setGridState('items', currItem => currItem.id === item.id, {
12
x: newLeft,
13
y: newTop,
14
});
15
}
16
}

And finally the memoizedCellValidation function. For this one we are going to utilize the createMemo function from Solid JS to wrap the isCellBlockValid function which will be responsible for veryfying if the cell we are trying to place an item in is valid by checking whether the cells occupiedBy field is true and different from the passed item’s id.

Let’s first define the isCellBlockValid function in the gridStore.ts file.

GridStore.ts
1
const isCellBlockValid = (newRow: number, newCol: number, item: Item, sizeX: number, sizeY: number) => {
2
for (let row = 0; row < sizeY; row++) {
3
for (let col = 0; col < sizeX; col++) {
4
const resultRow = newRow + row;
5
const resultCol = newCol + col;
6
if (resultRow >= MAXROWS || resultCol >= MAXCOLS) return false
7
8
const cell = cells[resultRow][resultCol];
9
if (cell.occupiedBy && cell.occupiedBy !== item.id ) {
10
return false;
11
}
12
}
13
}
14
15
return true
16
}

And since we don’t wish to run ths 2D array loop on every mouse move event when we haven’t even moved the item out of its own cell, we are going to memoize the function and make it run only if the cell (drop position) has changed.

Grid.tsx
1
const memoizedCellValidation = createMemo(() => {
2
const item = draggedItem();
3
const position = dropPosition();
4
5
if(!item || !position) return false
6
7
return isCellBlockValid(position.row, position.col, item, item.sizeX, item.sizeY)
8
})

Mouse up event

What’s left from the dragging logic is to handle the mouseup event. After we’ve verified that the new position of the item we are dragging is valid, we are going to:

  • Update the item’s row and col properties and mark the new cells as occupied by the item.
  • Remove the dragging class from the item.
  • Assign the offeSetLeft and offSetTop properties to the item’s x and y properties (in order to make the item appear as being ‘slotted’ into its new position).
  • Update our GridData model with the new item’s properties.
  • Reset all drag states.
Grid.tsx
1
const dropitem = () => {
2
const currentItem = gridInteractionState.draggedItem;
3
4
if (!gridInteractionState.dropPosition || !currentItem) return
5
6
gridState.itemRefs.get(currentItem!.id)!.classList.remove(styles.Dragging)
7
resetItemElement(currentItem)
8
9
const newCol = gridInteractionState.dropPosition!.col;
10
const newRow = gridInteractionState.dropPosition!.row;
11
const newX = cells[newRow][newCol].element?.offsetLeft!;
12
const newY = cells[newRow][newCol].element?.offsetTop!;
13
14
setGridState('items', item => item.id === currentItem.id, {
15
x: newX,
16
y: newY,
17
col: newCol,
18
row: newRow,
19
});
20
21
clearCell(currentItem)
22
occupyCell(newRow, newCol, currentItem)
23
updateModel(GridData);
24
}
25
26
// On mouse up, update the item's position if there is a valid drop target
27
const handleMouseUp = () => {
28
dropitem();
29
resetInteractionState()
30
};
store/gridInteractionStore
1
export const resetInteractionState = () => {
2
modifyMutable(
3
gridInteractionState,
4
produce((gridInteractionState) => {
5
gridInteractionState.dragging = false;
6
gridInteractionState.draggedItem = null;
7
gridInteractionState.dropPosition = null;
8
})
9
)
10
}

The clearCell function will remove the occupiedBy property from the cells the item is currently occupying.

GridStore.ts
1
// Frees cell or cells spot
2
const clearCell = (item: Item) => {
3
for (let row = 0; row < MAXROWS; row++) {
4
for (let col = 0; col < MAXCOLS; col++) {
5
const cell = cells[row][col];
6
if (cell.occupiedBy === item.id) cell.occupiedBy = undefined;
7
}
8
}
9
}

And of course we need to update our model. We will define a helper function for this - updateModel

utils/index.ts
1
export const updateModel = (model: object) => {
2
engine.updateWholeModel(model);
3
engine.synchronizeModels();
4
}

Now if you save the changes you should be able to drag the items around the grid.

Dropcell component

To help better visualize the grid’s interactions we are going to add a DropCell component which will be used to visualize the drop position of the item while dragging it.

DropCell.tsx
1
import { Component, Show } from "solid-js";
2
import {cells} from '../../store/gridStore';
3
import styles from './Grid.module.css';
4
import { gridInteractionState } from "../../store/gridInteractionStore";
5
6
const DropCell: Component = () => {
7
8
return (
9
<div class={styles.DropCellContainer}>
10
<Show when={gridInteractionState.dropPosition} keyed>
11
{(position) => {
12
const { col, row } = position;
13
const cellToShow = cells[row][col]
14
const currentItem = gridInteractionState.draggedItem;
15
return (<div
16
style={{ top: `${cellToShow?.element?.offsetTop}px`, left: `${cellToShow?.element?.offsetLeft}px` }}
17
class={`${styles[`Item-${currentItem?.sizeX}x${currentItem?.sizeY}`]} ${styles.DropCell}`}>
18
</div>)
19
}}
20
</Show>
21
</div>
22
)
23
}
24
25
export default DropCell

Add some styles

Grid.module.css
1
.DropCell {
2
position: absolute;
3
top: 0;
4
left: 0;
5
background-color: #d9d9d979;
6
pointer-events: none;
7
}
8
9
.DropCellContainer {
10
position: absolute;
11
width: 100%;
12
height: 100%;
13
}

The DropCell component will use the dropPosition and draggedItem states from our gridInteractionStore and will render a div element with the same size as the dragged item and position it on the cell the item is currently hovering on. If the position is invalid the DropCell will show on the initial position of the item. And since we have access to the current item we can use its size to set the size of the DropCell.

To make it all work we are utilizng the Show SolidJS component that lets us conditionally render components. In our case Show will render the DropCell only if the dropPosition state is not null.

Now let’s import the DropCell component in the Grid.tsx file and render it.

Grid.tsx
1
import DropCell from "./DropCell";
2
3
return (
4
<div ref={gridRef} class={styles.Grid} >
5
{cells.flat().map(cell => initCell(cell))}
6
7
<DropCell />
8
9
<For each={gridState.items} >

Swapping grid items

To make the grid’s interactions more intuitive we are going to add the ability to swap items. When the user drags an item over another item we are going to swap their positions, if it’s possible.

Currently if two items on the grid overlap, the dragged item will fallback to its starting position. We are going to extend the handleMouseMove function to swap their positions if there is an overlap by calling a handleOverlap function.

Grid.tsx
1
if (!memoizedCellValidation()) {
2
handleOverlap(row, col, currentItem)
3
gridInteractionState.dropPosition = { col: currentItemCol, row: currentItemRow }
4
}

We are going to handle the whole swapping interaction in our handleOvelrap function. The process will be as follows:

  • Find which item is being overlapped by the dragged item.
  • Calculate the new positions of both items based on the direction of the overlap.
  • Check if the new positions are valid.
  • If the swap is valid, execute the swap, update the item’s state, update the cell’s occupiedBy property to match the new positions of the items and set a cooldown flag to avoid rapid swaps on every mouse move.

Add a isSwapCooldown state to the store avoid rapid swaps

store/gridInteractionStore.ts
1
export const gridInteractionState = createMutable<gridInteractionType>({
2
dragging: false,
3
draggedItem: null as Item | null,
4
dropPosition: null as Position | null,
5
isSwapCooldown: false,
6
shouldDrag: true,
7
});
Grid.tsx
1
const handleOverlap = (row: number, col: number, currentItem: Item) => {
2
// Avoids rapid swap if items still overlap
3
if (gridInteractionState.isSwapCooldown) return
4
5
const overlappedItem = gridState.items.find(i => i.id === cells[row][col].occupiedBy)
6
// Early exit if trying to overlap with itself
7
if (!overlappedItem || overlappedItem === currentItem) return
8
9
// Calculate new positions regardless of direction
10
let newCurrentRow = currentItem.row;
11
let newCurrentCol = currentItem.col;
12
let newOverlappedRow = overlappedItem.row;
13
let newOverlappedCol = overlappedItem.col;
14
15
// Horizontal movement logic
16
if (currentItem.col !== overlappedItem.col) {
17
if (currentItem.col < overlappedItem.col) {
18
// Current item is coming from left
19
const sizeDifferenceX = overlappedItem.sizeX - currentItem.sizeX;
20
newOverlappedCol = currentItem.col;
21
newCurrentCol = overlappedItem.col + sizeDifferenceX;
22
} else {
23
// Current item is coming from right
24
const sizeDifferenceX = currentItem.sizeX - overlappedItem.sizeX;
25
newOverlappedCol = currentItem.col + sizeDifferenceX;
26
newCurrentCol = overlappedItem.col;
27
}
28
}
29
30
// Vertical movement logic
31
if (currentItem.row !== overlappedItem.row) {
32
if (currentItem.row < overlappedItem.row) {
33
// Current item is coming from top
34
const sizeDifferenceY = overlappedItem.sizeY - currentItem.sizeY;
35
newOverlappedRow = currentItem.row;
36
newCurrentRow = overlappedItem.row + sizeDifferenceY;
37
} else {
38
// Current item is coming from bottom
39
const sizeDifferenceY = currentItem.sizeY - overlappedItem.sizeY;
40
newOverlappedRow = currentItem.row + sizeDifferenceY;
41
newCurrentRow = overlappedItem.row;
42
}
43
}
44
45
// Check grid boundaries for both items
46
if (!isWithinGrid(newOverlappedRow, newOverlappedCol, overlappedItem) ||
47
!isWithinGrid(newCurrentRow, newCurrentCol, currentItem)) {
48
return;
49
}
50
51
// Validate swap for both items in their new positions
52
if (!isSwapValid(newOverlappedRow, newOverlappedCol, overlappedItem, currentItem) ||
53
!isSwapValid(newCurrentRow, newCurrentCol, currentItem, overlappedItem)) {
54
return;
55
}
56
57
// Execute the swap
58
const hasSwapped = swapItemPositions(
59
currentItem, overlappedItem,
60
newCurrentRow, newCurrentCol,
61
newOverlappedRow, newOverlappedCol
62
);
63
64
if (hasSwapped) {
65
clearCell(currentItem);
66
clearCell(overlappedItem);
67
occupyCell(currentItem.row, currentItem.col, currentItem);
68
occupyCell(overlappedItem.row, overlappedItem.col, overlappedItem);
69
gridInteractionState.isSwapCooldown = true
70
setTimeout(() => gridInteractionState.isSwapCooldown = false, 500);
71
}
72
}

In addition, we need to implement three helper functions to make the swap possible.

  • isWithinGrid - checks if the new position of the item is within the grid boundaries.
  • isSwapValid - checks if the swap is valid by verifying if the new position of the item is not occupied by another item.
  • swapItemPositions - swaps the positions of the two items by updating their state in the store.
Grid.tsx
1
const isWithinGrid = (row: number, col: number, item: Item) => {
2
return row >= 0 && (row + item.sizeY - 1) <= MAXROWS && col >= 0 && (col + item.sizeX - 1) <= MAXCOLS;
3
}

In the isSwapValid function we are going to loop through all the grid cells and check if the new position of the item is not occupied by another item. Excluding the overlapped and the dragged items.

Grid.tsx
1
const isSwapValid = (newRow: number, newCol: number, item: Item, overlappedItem: Item) => {
2
for (let row = 0; row < item.sizeY; row++) {
3
for (let col = 0; col < item.sizeX; col++) {
4
5
const resultRow = newRow + row;
6
const resultCol = newCol + col;
7
if (resultRow >= MAXROWS || resultCol >= MAXCOLS) return false
8
9
const cell = cells[resultRow][resultCol];
10
if (cell.occupiedBy && cell.occupiedBy !== item.id && cell.occupiedBy !== overlappedItem.id) {
11
return false;
12
}
13
}
14
}
15
16
return true
17
}

Finally, in the swapItemPositions function we are going to update the items’ state in the store and return a boolean indicating if the swap was successful. The function will accept all the new calculated row and cols for both items. We will move the common logic for state updating into its own separate function setGridItemState to reduce code repetitiveness

Grid.tsx
1
const swapItemPositions = (currentItem: Item, overlappedItem: Item, newCurrentRow: number, newCurrentCol: number, newOverlappedRow: number, newOverlappedCol: number) => {
2
// Set new locations for overlapped item
3
setGridState('items',
4
item => item.id === overlappedItem.id,
5
produce(setGridItemState(newOverlappedRow, newOverlappedCol))
6
);
7
8
// Set new locations for current item
9
setGridState('items',
10
item => item.id === currentItem.id,
11
produce(setGridItemState(newCurrentRow, newCurrentCol))
12
);
13
14
return true;
15
}
16
17
const setGridItemState = (newRow: number, newCol: number) => {
18
return (item: Item) => {
19
item.row = newRow;
20
item.col = newCol;
21
item.x = cells[item.row][item.col].element!.offsetLeft;
22
item.y = cells[item.row][item.col].element!.offsetTop;
23
}
24
}

Now if you save the changes you should be able to swap the items on the grid.

Resizing grid items

The final grid functionality we are going to implement will be the ability to resize the grid items.

The process will be similar to the dragging logic. We are going to create a resizing state in our interaction store to track if the item is being resized and a couple of helper variables to store:

  • initial width and height of the item - startWidth, startHeight
  • the new size of the item - newSizeX, newSizeY
Grid.tsx
1
let startLeft: number = 0,
2
startTop: number = 0,
3
startMouseX: number = 0,
4
startMouseY: number = 0,
5
startWidth: number = 0,
6
startHeight: number = 0,
7
newSizeX: number = 0,
8
newSizeY: number = 0,
9
offsetX: number = 0,
10
offsetY: number = 0;
store/gridInteractionStore.ts
1
export const gridInteractionState = createMutable<gridInteractionType>({
2
dragging: false,
3
resizing: false,
4
draggedItem: null as Item | null,
5
dropPosition: null as Position | null,
6
isSwapCooldown: false,
7
shouldDrag: true,
8
});

Next, we are going to extend the handleMouseDown function to check whether the user is dragging or resizing. We will check if the clicked element is the resize handle and set the resizing state to true.

Additionally, we are going to store the rect object of the grid element and assign it’s left and top properties as X and Y offset. We do this to ensure that the coordinates of the mouse are relative to the grid element.

Grid.tsx
1
const handleMouseDown = (e: MouseEvent, item: Item) => {
2
if (!gridRef || !gridInteractionState.shouldDrag) return;
3
4
gridInteractionState.draggedItem = item
5
const currentItemElement = gridState.itemRefs.get(item.id)
6
7
// Store the initial mouse position
8
startMouseX = e.clientX;
9
startMouseY = e.clientY;
10
11
const clickedElement = e.target as HTMLElement;
12
13
if (clickedElement.closest(`.${styles.CellItem}`)) startDragging(item)
14
15
if (clickedElement.closest(`.${styles['CellItem-drag']}`)) startResizing(currentItemElement);
16
else if (clickedElement.closest(`.${styles.CellItem}`)) startDragging(item)
17
18
e.preventDefault();
19
};
Grid.tsx
1
const startDragging = (item: Item) => {
2
startLeft = item.x;
3
startTop = item.y;
4
offsetX = startMouseX - startLeft;
5
offsetY = startMouseY - startTop;
6
gridInteractionState.dragging = true;
7
}

Next, we are also going to extend the handleMouseMove function to handle resizing the item. We are once again going to get the new position of the mouse and return the relevant row and col of the grid. And then we are going to call the changeSize function which will calculate the new size of the item based on the mouse position.

Grid.tsx
1
const handleMouseMove = (e:MouseEvent) => {
2
// Dragging logic
3
4
if (gridInteractionState.resizing && currentItem) {
5
const { col, row } = getGridPosition(itemleft, itemTop, MAXCOLS - 1, MAXROWS - 1)
6
changeSize(currentItem, mouseX, mouseY, col, row);
7
gridInteractionState.dropPosition = { col: currentItemCol, row: currentItemRow }
8
}
9
}
10
11
const changeSize = (item: Item, mouseX: number, mouseY: number, col: number, row: number) => {
12
const itemElement = gridState.itemRefs.get(item.id);
13
14
if (item && itemElement) {
15
16
const newWidth = startWidth + (mouseX - startMouseX);
17
const newHeight = startHeight + (mouseY - startMouseY);
18
19
itemElement.style.width = `${newWidth}px`;
20
itemElement.style.height = `${newHeight}px`;
21
22
newSizeX = clamp((col - item.col) + 1, item.maxSizeX, item.minSizeX);
23
newSizeY = clamp((row - item.row) + 1, item.maxSizeY, item.minSizeY)
24
25
if (isCellBlockValid(item.row, item.col, item, newSizeX, newSizeY)) {
26
setGridState('items', (currentItem) => currentItem.id === item.id, {
27
sizeX: newSizeX,
28
sizeY: newSizeY
29
})
30
}
31
}
32
}

In the changeSize function on every mouse move we are updating the width and height of the item’s html element so the user can see the resize in real-time. Also, since we want our items to have minimum and maximum size we are using the clamp function to limit the new size of the item.

Finally, we will check if the new size of the item is valid and not overlapping with another item by calling the isCellBlockValid function. If the new size is valid we are going to update the item’s state.

The last thing to do to complete the resize functionality is to reset the resizing state and the html element’s width and height styles we changed during the dragging process.

Grid.tsx
1
const dropitem = () => {
2
const currentItem = gridInteractionState.draggedItem;
3
4
if (!gridInteractionState.dropPosition || !currentItem) return
5
6
gridState.itemRefs.get(currentItem!.id)!.classList.remove(styles.Dragging)
7
resetItemElement(currentItem)
8
9
const newCol = gridInteractionState.dropPosition!.col;
10
const newRow = gridInteractionState.dropPosition!.row;
11
const newX = cells[newRow][newCol].element?.offsetLeft!;
12
const newY = cells[newRow][newCol].element?.offsetTop!;
13
14
setGridState('items', item => item.id === currentItem.id, {
15
x: newX,
16
y: newY,
17
col: newCol,
18
row: newRow,
19
});
20
21
clearCell(currentItem)
22
occupyCell(newRow, newCol, currentItem)
23
updateModel(GridData);
24
}
25
26
const resetItemElement = (currentItem: Item) => {
27
const itemElement = gridState.itemRefs.get(currentItem!.id)!;
28
29
itemElement.classList.remove(styles.Dragging)
30
itemElement.style.width = ''
31
itemElement.style.height = ''
32
}

And don’t forget to add the resizing state for reset in our store’s helper function - resetInteractionState

./store/gridInteractionStore.ts
1
export const resetInteractionState = () => {
2
modifyMutable(
3
gridInteractionState,
4
produce((gridInteractionState) => {
5
gridInteractionState.resizing = false;
6
gridInteractionState.dragging = false;
7
gridInteractionState.draggedItem = null;
8
gridInteractionState.dropPosition = null;
9
})
10
)
11
}

Now if you save the changes you should be able to resize the items on the grid.

Conclusion

We have created a grid component with drag and drop functionality. We have implemented the ability to drag, swap, and resize items on the grid.

In the next part of this tutorial series, we will create a side navigation component to add and remove items from the grid. Additionally, we will implement utility components such as a toast and tooltip.

TypeScript types

/types/types.ts
1
export interface Item {
2
id: number;
3
name: string;
4
x: number;
5
y: number;
6
row: number;
7
col: number;
8
sizeX: number;
9
sizeY: number;
10
minSizeX: number;
11
minSizeY: number;
12
maxSizeX: number;
13
maxSizeY: number;
14
element?: HTMLElement;
15
active: boolean;
16
}
17
18
export interface Position {
19
row: number;
20
col: number;
21
}
22
23
export interface GridCell {
24
element?: HTMLDivElement;
25
occupiedBy?: number;
26
}
27
28
export interface gridState {
29
itemRefs: Map<number, HTMLElement>
30
items: Item[]
31
}
32
33
export interface gridInteractionType {
34
dragging: boolean,
35
resizing: boolean,
36
draggedItem: Item | null,
37
dropPosition: Position | null,
38
isSwapCooldown: boolean,
39
shouldDrag: boolean,
40
}

To setup the types for cohtml.js you can follow this guide

Model types setup

Since the models are created at runtime, we need to define them as global types. We can do this by creating a global.d.ts file in the src folder, where we are going to define the types for all our models.

global.d.ts
1
import { Item } from "./types/types";
2
3
declare global {
4
let GridData: {gridItems: Item[]};
5
}
6
7
export {};

To make typescript recognize it as a global type, we need to add the following code in the tsconfig.json file.

tsconfig.json
1
{
2
"compilerOptions": {
3
// other options
4
},
5
"include": ["src", "src/global.d.ts"],
6
}

On this page