Skip to content

Commit 024b980

Browse files
committed
keydown and atoms moved to model
1 parent ad80b96 commit 024b980

File tree

1 file changed

+96
-81
lines changed

1 file changed

+96
-81
lines changed

frontend/app/view/launcher/launcher.tsx

Lines changed: 96 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import logoUrl from "@/app/asset/logo.svg?url";
5-
import { atoms, replaceBlock } from "@/app/store/global";
5+
import { atoms, globalStore, replaceBlock } from "@/app/store/global";
6+
import { checkKeyPressed, keydownWrapper } from "@/util/keyutil";
67
import { isBlank, makeIconClass } from "@/util/util";
78
import clsx from "clsx";
8-
import { atom, useAtomValue } from "jotai";
9-
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
9+
import { atom, useAtom, useAtomValue } from "jotai";
10+
import React, { useEffect, useLayoutEffect, useRef } from "react";
1011

1112
function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] {
1213
if (!wmap) return [];
@@ -15,13 +16,34 @@ function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | u
1516
return wlist;
1617
}
1718

19+
type GridLayoutType = { columns: number; tileWidth: number; tileHeight: number; showLabel: boolean };
20+
1821
export class LauncherViewModel implements ViewModel {
22+
blockId: string;
1923
viewType = "launcher";
2024
viewIcon = atom("shapes");
2125
viewName = atom("Widget Launcher");
2226
viewComponent = LauncherView;
2327
noHeader = atom(true);
2428
inputRef = { current: null } as React.RefObject<HTMLInputElement>;
29+
searchTerm = atom("");
30+
selectedIndex = atom(0);
31+
containerSize = atom({ width: 0, height: 0 });
32+
gridLayout: GridLayoutType = null;
33+
34+
constructor(blockId: string) {
35+
this.blockId = blockId;
36+
}
37+
38+
filteredWidgetsAtom = atom((get) => {
39+
const searchTerm = get(this.searchTerm);
40+
const widgets = sortByDisplayOrder(get(atoms.fullConfigAtom)?.widgets || {});
41+
return widgets.filter(
42+
(widget) =>
43+
!widget["display:hidden"] &&
44+
(!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase()))
45+
);
46+
});
2547

2648
giveFocus(): boolean {
2749
if (this.inputRef.current) {
@@ -30,26 +52,80 @@ export class LauncherViewModel implements ViewModel {
3052
}
3153
return false;
3254
}
55+
56+
keyDownHandler(e: WaveKeyboardEvent): boolean {
57+
if (this.gridLayout == null) {
58+
return;
59+
}
60+
const gridLayout = this.gridLayout;
61+
const filteredWidgets = globalStore.get(this.filteredWidgetsAtom);
62+
const selectedIndex = globalStore.get(this.selectedIndex);
63+
const rows = Math.ceil(filteredWidgets.length / gridLayout.columns);
64+
const currentRow = Math.floor(selectedIndex / gridLayout.columns);
65+
const currentCol = selectedIndex % gridLayout.columns;
66+
console.log("keydown", e);
67+
if (checkKeyPressed(e, "ArrowUp")) {
68+
if (currentRow > 0) {
69+
const newIndex = selectedIndex - gridLayout.columns;
70+
if (newIndex >= 0) {
71+
globalStore.set(this.selectedIndex, newIndex);
72+
}
73+
}
74+
return true;
75+
}
76+
if (checkKeyPressed(e, "ArrowDown")) {
77+
if (currentRow < rows - 1) {
78+
const newIndex = selectedIndex + gridLayout.columns;
79+
if (newIndex < filteredWidgets.length) {
80+
globalStore.set(this.selectedIndex, newIndex);
81+
}
82+
}
83+
return true;
84+
}
85+
if (checkKeyPressed(e, "ArrowLeft")) {
86+
if (currentCol > 0) {
87+
globalStore.set(this.selectedIndex, selectedIndex - 1);
88+
}
89+
return true;
90+
}
91+
if (checkKeyPressed(e, "ArrowRight")) {
92+
if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) {
93+
globalStore.set(this.selectedIndex, selectedIndex + 1);
94+
}
95+
return true;
96+
}
97+
if (checkKeyPressed(e, "Enter")) {
98+
if (filteredWidgets[selectedIndex]) {
99+
this.handleWidgetSelect(filteredWidgets[selectedIndex]);
100+
}
101+
return true;
102+
}
103+
if (checkKeyPressed(e, "Escape")) {
104+
globalStore.set(this.searchTerm, "");
105+
globalStore.set(this.selectedIndex, 0);
106+
return true;
107+
}
108+
return false;
109+
}
110+
111+
async handleWidgetSelect(widget: WidgetConfigType) {
112+
try {
113+
await replaceBlock(this.blockId, widget.blockdef);
114+
} catch (error) {
115+
console.error("Error replacing block:", error);
116+
}
117+
}
33118
}
34119

35120
const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId, model }) => {
36-
const fullConfig = useAtomValue(atoms.fullConfigAtom);
37-
const widgetMap = fullConfig?.widgets || {};
38-
const widgets = sortByDisplayOrder(widgetMap);
39-
40121
// Search and selection state
41-
const [searchTerm, setSearchTerm] = useState("");
42-
const [selectedIndex, setSelectedIndex] = useState(0);
43-
44-
// Filter widgets based on search term
45-
const filteredWidgets = widgets.filter(
46-
(widget) =>
47-
!widget["display:hidden"] && (!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase()))
48-
);
122+
const [searchTerm, setSearchTerm] = useAtom(model.searchTerm);
123+
const [selectedIndex, setSelectedIndex] = useAtom(model.selectedIndex);
124+
const filteredWidgets = useAtomValue(model.filteredWidgetsAtom);
49125

50126
// Container measurement
51127
const containerRef = useRef<HTMLDivElement>(null);
52-
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
128+
const [containerSize, setContainerSize] = useAtom(model.containerSize);
53129

54130
useLayoutEffect(() => {
55131
if (!containerRef.current) return;
@@ -79,7 +155,7 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
79155
const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0);
80156

81157
// Determine optimal grid layout
82-
const gridLayout = React.useMemo(() => {
158+
const gridLayout: GridLayoutType = React.useMemo(() => {
83159
if (containerSize.width === 0 || availableHeight <= 0 || filteredWidgets.length === 0) {
84160
return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true };
85161
}
@@ -103,73 +179,11 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
103179
}
104180
return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel };
105181
}, [containerSize, availableHeight, filteredWidgets.length]);
182+
model.gridLayout = gridLayout;
106183

107184
const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE);
108185
const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth;
109186

110-
// Handle widget selection and launch
111-
const handleWidgetSelect = async (widget: WidgetConfigType) => {
112-
try {
113-
await replaceBlock(blockId, widget.blockdef);
114-
} catch (error) {
115-
console.error("Error replacing block:", error);
116-
}
117-
};
118-
119-
// Keyboard navigation
120-
const handleKeyDown = useCallback(
121-
(e: KeyboardEvent) => {
122-
const rows = Math.ceil(filteredWidgets.length / gridLayout.columns);
123-
const currentRow = Math.floor(selectedIndex / gridLayout.columns);
124-
const currentCol = selectedIndex % gridLayout.columns;
125-
126-
switch (e.key) {
127-
case "ArrowUp":
128-
e.preventDefault();
129-
if (currentRow > 0) {
130-
const newIndex = selectedIndex - gridLayout.columns;
131-
if (newIndex >= 0) setSelectedIndex(newIndex);
132-
}
133-
break;
134-
case "ArrowDown":
135-
e.preventDefault();
136-
if (currentRow < rows - 1) {
137-
const newIndex = selectedIndex + gridLayout.columns;
138-
if (newIndex < filteredWidgets.length) setSelectedIndex(newIndex);
139-
}
140-
break;
141-
case "ArrowLeft":
142-
e.preventDefault();
143-
if (currentCol > 0) setSelectedIndex(selectedIndex - 1);
144-
break;
145-
case "ArrowRight":
146-
e.preventDefault();
147-
if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) {
148-
setSelectedIndex(selectedIndex + 1);
149-
}
150-
break;
151-
case "Enter":
152-
e.preventDefault();
153-
if (filteredWidgets[selectedIndex]) {
154-
handleWidgetSelect(filteredWidgets[selectedIndex]);
155-
}
156-
break;
157-
case "Escape":
158-
e.preventDefault();
159-
setSearchTerm("");
160-
setSelectedIndex(0);
161-
break;
162-
}
163-
},
164-
[selectedIndex, gridLayout.columns, filteredWidgets.length, handleWidgetSelect]
165-
);
166-
167-
// Set up keyboard listeners
168-
useEffect(() => {
169-
window.addEventListener("keydown", handleKeyDown);
170-
return () => window.removeEventListener("keydown", handleKeyDown);
171-
}, [handleKeyDown]);
172-
173187
// Reset selection when search term changes
174188
useEffect(() => {
175189
setSelectedIndex(0);
@@ -182,6 +196,7 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
182196
ref={model.inputRef}
183197
type="text"
184198
value={searchTerm}
199+
onKeyDown={keydownWrapper(model.keyDownHandler.bind(model))}
185200
onChange={(e) => setSearchTerm(e.target.value)}
186201
className="sr-only"
187202
aria-label="Search widgets"
@@ -204,7 +219,7 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
204219
{filteredWidgets.map((widget, index) => (
205220
<div
206221
key={index}
207-
onClick={() => handleWidgetSelect(widget)}
222+
onClick={() => model.handleWidgetSelect(widget)}
208223
title={widget.description || widget.label}
209224
className={clsx(
210225
"flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center",

0 commit comments

Comments
 (0)