As I could have hoped, the title should have intrigued you. I can assure you, your curiosity will be rewarded. This article is about how I accidentally rediscovered and reapplied concepts from Smart TV and Game Console UX designs to our beloved web.
THE PROBLEM
It sounds simple. Here are the scenarios:
- When I click on a TODO, the modal expands with the corresponding TODO data.
- When I click outside (and importantly, not on the TODO), the modal should collapse.
Simple, right? Simple for us humans, because this is how we logically think. But let us try to solve this problem the "traditional" way.
Note: I will provide 'pseudo' code, as I think it illustrates the point better. Disregard Angular-specific syntax like
@Componentor@HostBinding.
Traditional Approach: The Modal Component
class ModalComponent {
state = "collapsed"; // or "expanded"
// We have to listen to the ENTIRE page, because we need
// to know when the user clicks the "void".
onGlobalClick(clickEvent) {
// 1. Geography Check: Did the user click ME?
// If the click is inside the modal, obviously stay open.
if (this.contains(clickEvent.target)) {
return;
}
// 2. The Trap: The "Outside" Paradox
// The TODO item is technically "Outside".
// If we just close now, clicking a Todo will instantly
// open AND close the modal in the same millisecond.
// So, we start writing exceptions...
// HACK: We have to manually check if the click target
// was one of the Todo items.
if (clickEvent.target.classList.contains('todo-item')) {
// It was a Todo! Don't close. Let the Todo handle the open logic.
return;
}
// 3. Finally, if it wasn't me, and it wasn't a Todo...
// It must be the background.
this.collapse();
}
}
Observations and Analysis
Pros
- Speed: This solves our problem fast and now.
- Simplicity: No overengineering, just procedural thinking.
Cons
- Goodbye, reusability: We are strictly defining business logic inside the Modal component.
- Goodbye, flexibility: If our rules changed in the future - for example, if we wanted Project items instead of TODOs - we would have to go into the Modal component and manually change the conditions.
Conclusion
Garbage. I don't know about you, but I would feel terrible doing something like this. It defies good software principles.
So, I wanted to implement these requirements considering best practices. It comes with a cost. The cost is engineering something that removes the direct dependency of the Modal on the TODO.
The Genius Kicks In
Not gonna lie, I am very proud of what I came up with. If you stick with me until the very end, I hope you will appreciate the elegance and the "revolution" of the solution I implemented.
First, I realized one important concept. When a user interacts with the page, only one part of the page is actually in their focus.
Focus Theory
Let's visualize a simple TODO app page and split it into Focus Zones.
As you can see, the page can actually be divided into a couple of focus zones. I immediately wanted to conceptualize them in the code. Here is how it looks:
export abstract class FocusArea {
// Explained below, ignore this for now
abstract focusEngine: FocusEngine;
abstract id: string;
abstract isActive: Signal<boolean>;
abstract focus(): void;
abstract initialize(...): void;
abstract destroy(): void;
}
It is really simple. Importantly, we can focus() the zone, and we also have isActive — which tells us if the zone is active. Obvious, but essential.
Having them defined is a win, but we have to do something with them. What I want is to have two zones connected to each other, and for one zone to be able to trigger another zone. We are basically talking about two points connected to each other.
Graph Theory
The definition of this concept is as follows:
In mathematics and computer science, graph theory is the study of graphs, which are mathematical structures used to model pairwise relations between objects.
Hmmm...
relations between objects...
Sounds like exactly what we need.
So, the implementation plan became simple: represent the relations between zones as a graph. When I focus() a zone, I should be able to programmatically tell which zone is connected to the one I focused, and I can trigger whatever interaction I want.
Keep this simple concept in mind, as the following implementation might look a bit complex, but the inner core is exactly what I described above.
In order to conceptualize the graph, I need a separate entity.
Meet FocusEngine.
Pay attention, to the the data.
Before we write the logic, look closely at the graph property in the FocusEngine below:
TypeScript
abstract readonly graph: Map<FocusArea, Set<FocusArea>>;
I chose a Map where every Key is a FocusArea, and the Value is a Set of its neighbors. This isn't random. This is an Adjacency List.
Why? Because speed matters.
When I click a TODO item, I don't want to scan an array of 10,000 items to find connections. I want to perform a hash lookup — O(1) complexity - and get the exact list of neighbors instantly.
Key: TodoItem #42
Value: { ModalComponent }
Simple. Fast. Brutally effective.
Here is the abstract definition:
export interface FocusedGraphPart {
originArea: FocusArea;
connections: Set<FocusArea>;
}
export abstract class FocusEngine {
// Added for clarity; it doesn't have to be exposed in the public contract
abstract readonly focusedGraphPart: Signal<FocusedGraphPart | undefined>;
abstract readonly graph: Map<FocusArea, Set<FocusArea>>;
abstract readonly registry: Map<string, FocusArea>;
abstract readonly pendingConnections: Map<string, Set<string>>;
// A helper function to let us know if an Area is in focus
abstract isActive(area: FocusArea): boolean;
// Register an area as a node in the graph
abstract register(areaId: string, area: FocusArea, connectedIds?: string[]): void;
abstract unregister(areaId: string): void;
// Link 2 parts of the graph between each other
abstract link(area: FocusArea, areaB: FocusArea): void;
abstract unlink(area: FocusArea, areaB: FocusArea): void;
// Perform activation process if focusedArea has any connections
abstract activate(focusedArea: FocusArea): void;
}
Having FocusArea and FocusEngine defined, we want a middleman that will allow our Components to become a FocusArea and add themselves to the graph.
Meet FocusAreaDirective:
@Directive()
export abstract class FocusAreaDirective implements FocusArea {
public focusEngine = inject(FocusEngine);
private destroyRef = inject(DestroyRef);
public id!: string;
public isActive: Signal<boolean> = computed(() => {
return this.focusEngine.isActive(this);
})
// Important: notice connectedIds. This is how we define relations in the graph.
public initialize({areaId, connectedIds}: FocusAreaConfig) {
this.id = areaId;
this.focusEngine.register(this.id, this, connectedIds);
this.destroyRef.onDestroy(() => {
this.destroy();
})
}
public focus(): void {
this.focusEngine.activate(this)
}
public destroy(): void {
this.focusEngine.unregister(this.id);
}
@HostListener('click', ['$event'])
public onClick(event: MouseEvent): void {
event.stopPropagation();
this.focus();
}
@HostBinding('class.is-active')
get activeClass(): boolean {
return this.isActive();
}
}
So, if one of our Component's wants to become a FocusArea, it can simply do this:
export class TodoComponent extends FocusAreaDirective {
public todo = input.required<Todo>()
constructor() {
super();
effect(() => {
this.initialize({
areaId: `TODO_${this.todo().id}`,
connectedIds: ['TODO_CREATE_MODAL']
})
})
}
}
That's it!!!!
The Engineering Challenges
Before I show you the final code, we need to address two massive problems I ran into.
- The Secret headache:
GraphSymmetry You will notice something peculiar in my link method. When Area A connects to Area B, I also force Area B to connect to Area A.
this.graph.get(areaA).add(areaB);
this.graph.get(areaB).add(areaA); // The Mirror
You ask, "why do we need the mirror connections? The Modal doesn't care about the Todo!"
Actually, it does. It's about Cleanup.
Imagine the user navigates away and the Modal is destroyed. If the connection was only one-way (Todo -> Modal), the Todo would still be holding a reference to a dead Modal.
By enforcing Symmetry, when the Modal dies, it can look at its own list of friends and say, "Hey Todo, I'm leaving. Delete my number." It makes cleanup instantaneous without requiring a full graph scan.
- The "Chicken or Egg" Problem Now, if you are an experienced frontend developer, you are probably logically asking:
"But Alex! You can't just link things! What if the Todo loads before the Modal? The Modal doesn't exist yet!"
You are absolutely right. In the chaotic world of modern frameworks (Angular, React, Vue), we have zero guarantees about render order.
Scenario: TodoItem initializes. It says: "Connect me to TODO_MODAL!"
Reality: The ModalComponent hasn't rendered yet. It's undefined.
Result: The code crashes. Game over.
I spent three days banging my head against the wall on this(almost literally). The solution? The Waiting Room.
I introduced pendingConnections. It’s a simple "Post-it Note" system.
Todo tries to connect to Modal.
Engine sees Modal is missing.
Engine says: "Chill. I'll leave a note." It adds Todo ID to the pendingConnections list for the Modal.
Time passes...
Modal finally renders and registers itself.
Engine checks the notes: "Oh, Todo was waiting for you!" and instantly links them.
Now, it doesn't matter what loads first, second, or last. The graph effectively "heals" itself as components arrive.
The Implementation Details
Having the whole picture defined, let me show you the implementation of the brain behind all this fun(fuuun, of course).
FocusEngine Implementation:
export class FocusEngineService implements FocusEngine {
public readonly graph = new Map<FocusArea, Set<FocusArea>>();
public readonly registry = new Map<string, FocusArea>();
public readonly pendingConnections = new Map<string, Set<string>>;
public focusedGraphPart = signal<FocusedGraphPart | undefined>(undefined);
public register(areaId: string, area: FocusArea, connectedIds?: string[]): void {
this.registry.set(areaId, area);
const registeredArea = this.registry.get(areaId)!;
if (connectedIds) {
connectedIds.forEach((connectionId: string) => {
const connectedArea = this.registry.get(connectionId);
if (connectedArea) {
this.link(registeredArea, connectedArea);
} else {
this.pendingConnections.set(
connectionId,
(this.pendingConnections.get(connectionId) ?? new Set<string>()).add(areaId)
);
}
})
}
if (this.pendingConnections.has(areaId)) {
this.resolvePendingConnectionsOfArea(areaId);
}
}
// We create a mirror connection to support future cleanup (Symmetry)
public link(areaA: FocusArea, areaB: FocusArea): void {
this.graph.set(areaA, (this.graph.get(areaA) ?? new Set<FocusArea>()).add(areaB))
this.graph.set(areaB, (this.graph.get(areaB) ?? new Set<FocusArea>()).add(areaA))
}
public unlink(areaA: FocusArea, areaB: FocusArea): void {
const areaAConnections = this.graph.get(areaA);
const areaBConnections = this.graph.get(areaB);
if (areaAConnections) {
areaAConnections.delete(areaB);
}
if (areaBConnections) {
areaBConnections.delete(areaA);
}
}
public unregister(areaId: string) {
const area = this.registry.get(areaId);
if (area) {
const areaConnections = this.graph.get(area);
if (areaConnections) {
[...areaConnections].forEach((connection) => {
this.unlink(area, connection);
})
}
this.registry.delete(areaId);
this.graph.delete(area);
}
}
public activate(focusedArea: FocusArea): void {
const registryFocusedArea = this.registry.get(focusedArea.id);
if (registryFocusedArea) {
this.focusedGraphPart.set({
originArea: focusedArea,
connections: this.graph.get(registryFocusedArea) ?? new Set()
});
}
}
public isActive(area: FocusArea): boolean {
const focusedGraphPart = this.focusedGraphPart();
return focusedGraphPart ? focusedGraphPart.originArea === area || focusedGraphPart.connections.has(area) : false
}
private resolvePendingConnectionsOfArea(areaId: string): void {
const pendingConnections = this.pendingConnections.get(areaId)!;
pendingConnections.forEach((dependentId: string) => {
const connectedArea = this.registry.get(dependentId);
if (connectedArea) {
this.link(this.registry.get(areaId)!, connectedArea);
}
})
this.pendingConnections.delete(areaId);
}
}
This is kind of it. We solved our problem.
Now the code in the modal component looks amazingly simple.
In the modal component, we can simply do:
this.initialize({areaId: 'TODO_CREATE_MODAL', connectedIds: []});
// Treat this as a reaction to the TODO click.
// The modal becomes active and listens to the `cardHeight` variable so it can adjust its height.
effect(() => {
this.cardHeight = this.isActive() ? `${config.maximumSize}px` : `${config.minimumSize}px`;
});
In the TODO component, we can do this:
this.initialize({areaId: `TODO_${this.todo().id}`, connectedIds: ['TODO_CREATE_MODAL']})
The full code can be found in this repo: Live Todo Repo
Feel free to clone it and observe everything in action. I am too tired to add more details, but I believe that those who are interested will have enough curiosity to investigate. I don't want to serve everything on a silver platter, fellow developers!
Also, as a cherry on top, when I also registered the whole backgound of the app as separate node with **no connections.
When I click on the background, another part of the graph just simply activates, so modal and todo becomes inactive.
No need for modal to even know about this. That was the entire point.
Closing Thoughts
Being curious about why I had to go through literal hell to build this, I asked my fellow Gemini: "Bro, why did no one come up with this idea before me?"
What I found left me in shock.
This WAS done before. Not in the Web universe, but in the universe of Smart TVs and Game Consoles.
Thinking about this in retrospect, it becomes pretty obvious. I am a real enjoyer of video games, and console developers had to solve this problem because the user doesn't have a mouse. They only have arrow keys to click. So user interaction literally looks like this: Area 1 -> (arrowRight) -> Area 2 -> Area 3, etc. Console devs needed a map of possible interactions for every point on the screen.
And now, thanks to this experiment, this pattern has migrated to the Web.
All the best,
Yours, Alex.


Top comments (0)