improves a11y handling for menu
5 files changed,  +471, -90
M client/lib/es6/src/ffi/PreactDOM.res.mjs
+3, -0
 1@@ -55,6 +55,8 @@ function addEventListener(element, eventName, handler) {
 2 
 3 let KeyboardEvent = {};
 4 
 5+let MouseEvent = {};
 6+
 7 let InputElement = {};
 8 
 9 export {
10@@ -63,6 +65,7 @@ export {
11   Ref,
12   addEventListener,
13   KeyboardEvent,
14+  MouseEvent,
15   InputElement,
16 }
17 /* No side effect */
M client/lib/es6/src/ui/FloatingMenu.res.mjs
+243, -66
  1@@ -1,8 +1,11 @@
  2 // Generated by ReScript, PLEASE EDIT WITH CARE
  3 
  4+import * as Log from "../utils/Log.res.mjs";
  5 import * as Editor from "./Editor.res.mjs";
  6 import * as NanoID from "../ffi/NanoID.res.mjs";
  7 import * as Preact from "../ffi/Preact.res.mjs";
  8+import * as PreactDOM from "../ffi/PreactDOM.res.mjs";
  9+import * as Hooks from "preact/hooks";
 10 import * as LucidePreact from "lucide-preact";
 11 import * as Signals from "@preact/signals";
 12 import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
 13@@ -11,6 +14,8 @@ import * as Menus from "@tiptap/react/menus";
 14 
 15 let show = Signals.signal(false);
 16 
 17+let focusedIndex = Signals.signal(0);
 18+
 19 let state = {
 20   timeout: null,
 21   editor: null
 22@@ -36,7 +41,11 @@ function stopClose() {
 23 
 24 function setEditor(e) {
 25   state.editor = e;
 26-  return e.isFocused;
 27+  if (e.isFocused) {
 28+    return true;
 29+  } else {
 30+    return show.value;
 31+  }
 32 }
 33 
 34 function $$do(callback) {
 35@@ -96,87 +105,253 @@ function selectFn(sig) {
 36   };
 37 }
 38 
 39+function selectWaypoint(docID) {
 40+  let sig = Editor.onSearchSelectCache.get(docID);
 41+  if (sig === undefined) {
 42+    return;
 43+  }
 44+  let sig$1 = Primitive_option.valFromOption(sig);
 45+  sig$1.value = selectFn(sig$1);
 46+}
 47+
 48 function waypointClickHandler(docID) {
 49-  return _evt => {
 50-    let sig = Editor.onSearchSelectCache.get(docID);
 51-    if (sig === undefined) {
 52-      return;
 53-    }
 54-    let sig$1 = Primitive_option.valFromOption(sig);
 55-    sig$1.value = selectFn(sig$1);
 56-  };
 57+  return _evt => selectWaypoint(docID);
 58 }
 59 
 60 function FloatingMenu(props) {
 61-  let btn = "bg-[var(--color-paper)] py-2 md:py-1 w-screen md:w-full text-xl md:text-sm hover:cursor-pointer hover:bg-[var(--color-blue-100)] flex flex-row items-center";
 62+  let doc = props.doc;
 63+  let baseBtn = "py-3 md:py-1 w-full text-lg md:text-sm hover:cursor-pointer flex flex-row items-center";
 64+  let highlighted = " bg-[var(--color-blue-100)]";
 65+  let containerRef = Hooks.useRef(null);
 66+  let menuRef = Hooks.useRef(null);
 67+  let item0Id = "floating-menu-waypoint-" + doc;
 68+  let item1Id = "floating-menu-image-" + doc;
 69+  let item2Id = "floating-menu-video-" + doc;
 70+  let getItemId = index => {
 71+    switch (index) {
 72+      case 1 :
 73+        return item1Id;
 74+      case 2 :
 75+        return item2Id;
 76+      default:
 77+        return item0Id;
 78+    }
 79+  };
 80+  Signals.effect(() => {
 81+    if (!show.value) {
 82+      return;
 83+    }
 84+    let el = menuRef.current;
 85+    if (!(el == null)) {
 86+      el.focus();
 87+      return;
 88+    }
 89+  });
 90+  Signals.effect(() => {
 91+    if (!show.value) {
 92+      focusedIndex.value = 0;
 93+      return;
 94+    }
 95+  });
 96+  Hooks.useEffect(() => {
 97+    let handleKeyDown = evt => {
 98+      Log.info(`key: ` + evt.key, undefined);
 99+      if (evt.metaKey && evt.shiftKey && evt.key === "P") {
100+        evt.preventDefault();
101+        Log.info("got menu open shortcut", undefined);
102+        if (!show.value) {
103+          show.value = true;
104+          return;
105+        } else {
106+          return;
107+        }
108+      }
109+    };
110+    let documentElement = document;
111+    let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown);
112+    return () => cleanup();
113+  }, []);
114+  let isOpen = show.value;
115+  Hooks.useEffect(() => {
116+    if (!isOpen) {
117+      return;
118+    }
119+    let handleKeyDown = evt => {
120+      let key = evt.key;
121+      let shiftKey = evt.shiftKey;
122+      let current = focusedIndex.value;
123+      let exit = 0;
124+      switch (key) {
125+        case "ArrowUp" :
126+          evt.preventDefault();
127+          let prev = current > 0 ? current - 1 | 0 : 2;
128+          focusedIndex.value = prev;
129+          break;
130+        case "Enter" :
131+          if (current === 0) {
132+            selectWaypoint(doc);
133+          }
134+          break;
135+        case "Escape" :
136+          evt.preventDefault();
137+          show.value = false;
138+          let e = state.editor;
139+          if (!(e == null)) {
140+            e.commands.focus();
141+          }
142+          break;
143+        case "ArrowDown" :
144+        case "Tab" :
145+          exit = 1;
146+          break;
147+      }
148+      if (exit === 1 && !(key === "Tab" && shiftKey)) {
149+        evt.preventDefault();
150+        let next = current < 2 ? current + 1 | 0 : 0;
151+        focusedIndex.value = next;
152+      }
153+      if (!(key === "Tab" && shiftKey)) {
154+        return;
155+      }
156+      evt.preventDefault();
157+      let prev$1 = current > 0 ? current - 1 | 0 : 2;
158+      focusedIndex.value = prev$1;
159+    };
160+    let documentElement = document;
161+    let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown);
162+    return () => cleanup();
163+  }, [isOpen]);
164+  Hooks.useEffect(() => {
165+    if (!isOpen) {
166+      return;
167+    }
168+    let handleMouseDown = evt => {
169+      let target = evt.target;
170+      let container = containerRef.current;
171+      if (container == null) {
172+        return;
173+      }
174+      let isInside = container.contains(target);
175+      if (isInside) {
176+        return;
177+      }
178+      show.value = false;
179+      let e = state.editor;
180+      if (!(e == null)) {
181+        e.commands.focus();
182+        return;
183+      }
184+    };
185+    let documentElement = document;
186+    let cleanup = PreactDOM.addEventListener(documentElement, "mousedown", handleMouseDown);
187+    return () => cleanup();
188+  }, [isOpen]);
189+  let index = focusedIndex.value;
190   return JsxRuntime.jsx(Menus.FloatingMenu, {
191     options: {
192       placement: "left-start"
193     },
194     shouldShow: props => setEditor(props.editor),
195-    children: JsxRuntime.jsx("div", {
196-      children: show.value ? JsxRuntime.jsxs("div", {
197-          children: [
198-            JsxRuntime.jsxs("button", {
199-              children: [
200-                JsxRuntime.jsx(LucidePreact.LocateFixed, {
201-                  className: "p-[2px]"
202-                }),
203-                JsxRuntime.jsx("span", {
204-                  children: "Add waypoint",
205-                  className: "ml-2"
206-                })
207-              ],
208-              className: btn,
209-              onClick: waypointClickHandler(props.doc)
210-            }),
211-            JsxRuntime.jsxs("button", {
212-              children: [
213-                JsxRuntime.jsx(LucidePreact.Image, {
214-                  className: "p-[2px]"
215-                }),
216-                JsxRuntime.jsx("span", {
217-                  children: "Upload image",
218-                  className: "ml-2"
219-                }),
220-                JsxRuntime.jsx("span", {
221-                  children: " (soon!)",
222-                  className: "italic ml-2 text-xs"
223-                })
224-              ],
225-              className: btn,
226-              disabled: true
227-            }),
228-            JsxRuntime.jsxs("button", {
229-              children: [
230-                JsxRuntime.jsx(LucidePreact.SquarePlay, {
231-                  className: "p-[2px]"
232-                }),
233-                JsxRuntime.jsx("span", {
234-                  children: "Link YT Video",
235-                  className: "ml-2"
236-                }),
237-                JsxRuntime.jsx("span", {
238-                  children: " (soon!)",
239-                  className: "italic ml-2 text-xs"
240-                })
241-              ],
242-              className: btn,
243-              disabled: true
244-            })
245-          ],
246-          className: "w-screen h-screen md:w-fit md:h-fit fixed md:relative top-0 left-0 pt-2 px-2 md:px-0 md:pt-0 flex flex-col md:rounded-sm bg-[var(--color-paper)]",
247-          onMouseEnter: _evt => stopClose(),
248-          onMouseLeave: _evt => closeMenu()
249-        }) : JsxRuntime.jsx("button", {
250+    children: JsxRuntime.jsxs("div", {
251+      children: [
252+        JsxRuntime.jsx("button", {
253           children: JsxRuntime.jsx(LucidePreact.GripVertical, {
254             className: "w-5"
255           }),
256+          "aria-expanded": show.value,
257+          "aria-haspopup": "menu",
258           className: "hover:cursor-pointer hover:bg-[var(--color-bg-2)] rounded-sm text-[var(--color-blue)] my-auto",
259+          type: "button",
260           onClick: _evt => {
261-            show.value = true;
262+            show.value = !show.value;
263           }
264         }),
265+        show.value ? JsxRuntime.jsxs("div", {
266+            children: [
267+              JsxRuntime.jsxs("button", {
268+                children: [
269+                  JsxRuntime.jsx(LucidePreact.LocateFixed, {
270+                    className: "p-[2px]"
271+                  }),
272+                  JsxRuntime.jsx("span", {
273+                    children: "Add waypoint",
274+                    className: "ml-2"
275+                  })
276+                ],
277+                className: baseBtn + (
278+                  index === 0 ? highlighted : " bg-[var(--color-paper)]"
279+                ),
280+                id: item0Id,
281+                role: "menuitem",
282+                tabIndex: -1,
283+                onClick: _evt => selectWaypoint(doc),
284+                onMouseEnter: _evt => {
285+                  focusedIndex.value = 0;
286+                }
287+              }),
288+              JsxRuntime.jsxs("button", {
289+                children: [
290+                  JsxRuntime.jsx(LucidePreact.Image, {
291+                    className: "p-[2px]"
292+                  }),
293+                  JsxRuntime.jsx("span", {
294+                    children: "Upload image",
295+                    className: "ml-2"
296+                  }),
297+                  JsxRuntime.jsx("span", {
298+                    children: " (soon!)",
299+                    className: "italic ml-2 text-xs"
300+                  })
301+                ],
302+                "aria-disabled": true,
303+                className: baseBtn + (
304+                  index === 1 ? highlighted : " bg-[var(--color-paper)]"
305+                ),
306+                id: item1Id,
307+                role: "menuitem",
308+                tabIndex: -1,
309+                disabled: true,
310+                onMouseEnter: _evt => {
311+                  focusedIndex.value = 1;
312+                }
313+              }),
314+              JsxRuntime.jsxs("button", {
315+                children: [
316+                  JsxRuntime.jsx(LucidePreact.SquarePlay, {
317+                    className: "p-[2px]"
318+                  }),
319+                  JsxRuntime.jsx("span", {
320+                    children: "Link YT Video",
321+                    className: "ml-2"
322+                  }),
323+                  JsxRuntime.jsx("span", {
324+                    children: " (soon!)",
325+                    className: "italic ml-2 text-xs"
326+                  })
327+                ],
328+                "aria-disabled": true,
329+                className: baseBtn + (
330+                  index === 2 ? highlighted : " bg-[var(--color-paper)]"
331+                ),
332+                id: item2Id,
333+                role: "menuitem",
334+                tabIndex: -1,
335+                disabled: true,
336+                onMouseEnter: _evt => {
337+                  focusedIndex.value = 2;
338+                }
339+              })
340+            ],
341+            ref: Primitive_option.some(menuRef),
342+            "aria-activedescendant": getItemId(index),
343+            className: "flex flex-col active:outline-0 focus:outline-0",
344+            role: "menu",
345+            tabIndex: 0,
346+            onMouseEnter: _evt => stopClose(),
347+            onMouseLeave: _evt => closeMenu()
348+          }) : null
349+      ],
350+      ref: Primitive_option.some(containerRef),
351       className: Preact.cls("fixed left-0 box-border flex flex-row items-center", {
352         "shadow-xl/20 bg-[var(--color-paper)] ring-2 p-[2px] ring-[var(--color-blue)] rounded-sm ml-2": show.value
353       })
354@@ -188,6 +363,7 @@ let make = FloatingMenu;
355 
356 export {
357   show,
358+  focusedIndex,
359   state,
360   closeMenu,
361   stopClose,
362@@ -196,6 +372,7 @@ export {
363   content,
364   html,
365   selectFn,
366+  selectWaypoint,
367   waypointClickHandler,
368   make,
369 }
M client/src/ffi/PreactDOM.res
+11, -0
 1@@ -112,6 +112,17 @@ module KeyboardEvent = {
 2   @send external stopPropagation: t => unit = "stopPropagation"
 3 }
 4 
 5+// Native DOM MouseEvent bindings for document-level handlers
 6+module MouseEvent = {
 7+  type t
 8+
 9+  @get external target: t => Dom.element = "target"
10+  @get external clientX: t => int = "clientX"
11+  @get external clientY: t => int = "clientY"
12+  @send external preventDefault: t => unit = "preventDefault"
13+  @send external stopPropagation: t => unit = "stopPropagation"
14+}
15+
16 // HTMLInputElement bindings for form inputs
17 module InputElement = {
18   // Type for HTMLInputElement, extends Dom.element
M client/src/ui/FloatingMenu.res
+213, -24
  1@@ -1,14 +1,15 @@
  2 open Preact
  3+
  4 let show = Signal.make(false)
  5+let focusedIndex = Signal.make(0)
  6 
  7 type t = {
  8   mutable timeout: nullable<Window.timeoutId>,
  9   mutable editor: nullable<Editor.t>,
 10 }
 11 
 12-// menu close handling - must be outside the box more than 500ms then auto close
 13-// if overshoot a little and go back into the box, it stays open
 14 let state = {timeout: Nullable.null, editor: Nullable.null}
 15+
 16 let closeMenu = () => {
 17   switch state.timeout->Nullable.toOption {
 18   | Some(id) => Window.clearTimeout(id)
 19@@ -16,18 +17,19 @@ let closeMenu = () => {
 20   }
 21   state.timeout = Nullable.make(Window.setTimeout(() => show->Signal.set(false), 500))
 22 }
 23+
 24 let stopClose = () => {
 25   switch state.timeout->Nullable.toOption {
 26   | Some(id) => Window.clearTimeout(id)
 27   | None => ()
 28   }
 29 }
 30-// store a mutable reference to the current editor every time the menu opens
 31+
 32 let setEditor = (e: Editor.t) => {
 33   state.editor = Nullable.make(e)
 34-  // returns a bool indicating whether it should open, when focused
 35-  e->Tiptap.isFocused
 36+  e->Tiptap.isFocused || show->Signal.get
 37 }
 38+
 39 let do = (callback: Editor.t => unit) => {
 40   switch state.editor->Nullable.toOption {
 41   | Some(e) => callback(e)
 42@@ -35,6 +37,7 @@ let do = (callback: Editor.t => unit) => {
 43   }
 44   show->Signal.set(false)
 45 }
 46+
 47 let content = (c: {..}) => {
 48   switch state.editor->Nullable.toOption {
 49   | Some(e) => e->Tiptap.Commands.insertContent(c->Obj.magic)
 50@@ -42,6 +45,7 @@ let content = (c: {..}) => {
 51   }
 52   show->Signal.set(false)
 53 }
 54+
 55 let html = (h: string) => {
 56   switch state.editor->Nullable.toOption {
 57   | Some(e) => e->Tiptap.Commands.insertContent(h->Obj.magic)
 58@@ -68,7 +72,6 @@ let selectFn = sig =>
 59     })
 60 
 61     let content2 = Obj.magic({"type": "text", "content": " "})
 62-    // Insert at the range from the event
 63     switch state.editor->Nullable.toOption {
 64     | Some(editor) => {
 65         editor->Tiptap.Commands.insertContent(content1)
 66@@ -80,21 +83,175 @@ let selectFn = sig =>
 67     sig->Preact.Signal.set(None)
 68   }
 69 
 70+let selectWaypoint = (docID: string) => {
 71+  switch Editor.onSearchSelectCache->Map.get(docID) {
 72+  | Some(sig) => sig->Preact.Signal.set(Some(selectFn(sig)))
 73+  | None => ()
 74+  }
 75+}
 76+
 77 let waypointClickHandler = (docID: string) =>
 78   _evt => {
 79-    switch Editor.onSearchSelectCache->Map.get(docID) {
 80-    | Some(sig) => sig->Preact.Signal.set(Some(selectFn(sig)))
 81-    | None => ()
 82-    }
 83+    selectWaypoint(docID)
 84   }
 85 
 86 @jsx.component
 87 let make = (~doc: string) => {
 88-  let btn = "bg-[var(--color-paper)] py-2 md:py-1 w-screen md:w-full text-xl md:text-sm hover:cursor-pointer hover:bg-[var(--color-blue-100)] flex flex-row items-center"
 89+  let baseBtn = "py-3 md:py-1 w-full text-lg md:text-sm hover:cursor-pointer flex flex-row items-center"
 90+  let highlighted = " bg-[var(--color-blue-100)]"
 91+  let containerRef = Preact.useRef(Nullable.null)
 92+  let menuRef = Preact.useRef(Nullable.null)
 93+
 94+  let item0Id = "floating-menu-waypoint-" ++ doc
 95+  let item1Id = "floating-menu-image-" ++ doc
 96+  let item2Id = "floating-menu-video-" ++ doc
 97+
 98+  let getItemId = (index: int) => {
 99+    switch index {
100+    | 0 => item0Id
101+    | 1 => item1Id
102+    | 2 => item2Id
103+    | _ => item0Id
104+    }
105+  }
106+
107+  // Focus the menu container when it opens
108+  Preact.Signal.effect(() => {
109+    if show->Signal.get {
110+      switch menuRef.current->Nullable.toOption {
111+      | Some(el) => el->PreactDOM.focus()
112+      | None => ()
113+      }
114+    }
115+  })
116+
117+  // Reset focusedIndex when menu closes
118+  Preact.Signal.effect(() => {
119+    if !(show->Signal.get) {
120+      focusedIndex->Signal.set(0)
121+    }
122+  })
123+
124+  // Global keyboard shortcut: Cmd+Shift+P opens menu
125+  Preact.useEffect(() => {
126+    let handleKeyDown = (evt: PreactDOM.KeyboardEvent.t) => {
127+      if (
128+        evt->PreactDOM.KeyboardEvent.metaKey &&
129+        evt->PreactDOM.KeyboardEvent.shiftKey &&
130+        evt->PreactDOM.KeyboardEvent.key == "P"
131+      ) {
132+        evt->PreactDOM.KeyboardEvent.preventDefault
133+        if !(show->Signal.get) {
134+          show->Signal.set(true)
135+        }
136+      }
137+    }
138+    let documentElement = Obj.magic(WebAPI.Global.document)
139+    let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown)
140+    Some(() => cleanup())
141+  }, [])
142+
143+  let isOpen = show->Signal.get
144+
145+  // Keyboard navigation when menu is open
146+  Preact.useEffect1(() => {
147+    if isOpen {
148+      let handleKeyDown = (evt: PreactDOM.KeyboardEvent.t) => {
149+        let key = evt->PreactDOM.KeyboardEvent.key
150+        let shiftKey = evt->PreactDOM.KeyboardEvent.shiftKey
151+        let current = focusedIndex->Signal.get
152+
153+        switch key {
154+        | "Escape" => {
155+            evt->PreactDOM.KeyboardEvent.preventDefault
156+            show->Signal.set(false)
157+            switch state.editor->Nullable.toOption {
158+            | Some(e) => e->Tiptap.Commands.focus
159+            | None => ()
160+            }
161+          }
162+        | "ArrowDown" | "Tab" =>
163+          if key == "Tab" && shiftKey {
164+            () // Shift+Tab handled below
165+          } else {
166+            evt->PreactDOM.KeyboardEvent.preventDefault
167+            let next = if current < 2 {
168+              current + 1
169+            } else {
170+              0
171+            }
172+            focusedIndex->Signal.set(next)
173+          }
174+        | "ArrowUp" => {
175+            evt->PreactDOM.KeyboardEvent.preventDefault
176+            let prev = if current > 0 {
177+              current - 1
178+            } else {
179+              2
180+            }
181+            focusedIndex->Signal.set(prev)
182+          }
183+        | "Enter" =>
184+          if current == 0 {
185+            selectWaypoint(doc)
186+          }
187+        // Enter on disabled items (1, 2) is a no-op
188+        | _ => ()
189+        }
190+
191+        if key == "Tab" && shiftKey {
192+          evt->PreactDOM.KeyboardEvent.preventDefault
193+          let prev = if current > 0 {
194+            current - 1
195+          } else {
196+            2
197+          }
198+          focusedIndex->Signal.set(prev)
199+        }
200+      }
201+
202+      let documentElement = Obj.magic(WebAPI.Global.document)
203+      let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown)
204+      Some(() => cleanup())
205+    } else {
206+      None
207+    }
208+  }, [isOpen])
209+
210+  // Outside click closes menu
211+  Preact.useEffect1(() => {
212+    if isOpen {
213+      let handleMouseDown = (evt: PreactDOM.MouseEvent.t) => {
214+        let target = evt->PreactDOM.MouseEvent.target
215+        switch containerRef.current->Nullable.toOption {
216+        | Some(container) => {
217+            let isInside = (container->Obj.magic)["contains"](target)
218+            if !isInside {
219+              show->Signal.set(false)
220+              switch state.editor->Nullable.toOption {
221+              | Some(e) => e->Tiptap.Commands.focus
222+              | None => ()
223+              }
224+            }
225+          }
226+        | None => ()
227+        }
228+      }
229+      let documentElement = Obj.magic(WebAPI.Global.document)
230+      let cleanup = PreactDOM.addEventListener(documentElement, "mousedown", handleMouseDown)
231+      Some(() => cleanup())
232+    } else {
233+      None
234+    }
235+  }, [isOpen])
236+
237+  let index = focusedIndex->Signal.get
238+
239   <Editor.FloatingMenu
240     shouldShow={props => setEditor(props.editor)} options={{placement: #"left-start"}}
241   >
242     <div
243+      ref={PreactDOM.Ref.domRef(containerRef)}
244       className={cls(
245         "fixed left-0 box-border flex flex-row items-center",
246         dict{
247@@ -102,35 +259,67 @@ let make = (~doc: string) => {
248         },
249       )}
250     >
251-      {!(show->Signal.get)
252-        ? <button
253-            className="hover:cursor-pointer hover:bg-[var(--color-bg-2)] rounded-sm text-[var(--color-blue)] my-auto"
254-            onClick={_evt => show->Signal.set(true)}
255-          >
256-            <Icons.GripVertical className="w-5" />
257-          </button>
258-        : <div
259-            className="w-screen h-screen md:w-fit md:h-fit fixed md:relative top-0 left-0 pt-2 px-2 md:px-0 md:pt-0 flex flex-col md:rounded-sm bg-[var(--color-paper)]"
260+      <button
261+        type_="button"
262+        className="hover:cursor-pointer hover:bg-[var(--color-bg-2)] rounded-sm text-[var(--color-blue)] my-auto"
263+        onClick={_evt => show->Signal.set(!(show->Signal.get))}
264+        ariaHaspopup={#menu}
265+        ariaExpanded={show->Signal.get}
266+      >
267+        <Icons.GripVertical className="w-5" />
268+      </button>
269+
270+      {show->Signal.get
271+        ? <div
272+            ref={PreactDOM.Ref.domRef(menuRef)}
273+            className="flex flex-col active:outline-0 focus:outline-0"
274             onMouseLeave={_evt => closeMenu()}
275             onMouseEnter={_evt => stopClose()}
276+            role="menu"
277+            tabIndex={0}
278+            ariaActivedescendant={getItemId(index)}
279           >
280-            <button className={btn} onClick={waypointClickHandler(doc)}>
281+            <button
282+              id={item0Id}
283+              className={baseBtn ++ (index === 0 ? highlighted : " bg-[var(--color-paper)]")}
284+              onClick={waypointClickHandler(doc)}
285+              onMouseEnter={_evt => focusedIndex->Signal.set(0)}
286+              role="menuitem"
287+              tabIndex={-1}
288+            >
289               <Icons.Waypoint className="p-[2px]" />
290               <span className="ml-2"> {"Add waypoint"->Preact.string} </span>
291             </button>
292 
293-            <button className={btn} disabled={true}>
294+            <button
295+              id={item1Id}
296+              className={baseBtn ++ (index === 1 ? highlighted : " bg-[var(--color-paper)]")}
297+              disabled={true}
298+              onMouseEnter={_evt => focusedIndex->Signal.set(1)}
299+              role="menuitem"
300+              ariaDisabled={true}
301+              tabIndex={-1}
302+            >
303               <Icons.Image className="p-[2px]" />
304               <span className="ml-2"> {"Upload image"->Preact.string} </span>
305               <span className="italic ml-2 text-xs"> {" (soon!)"->Preact.string} </span>
306             </button>
307 
308-            <button className={btn} disabled={true}>
309+            <button
310+              id={item2Id}
311+              className={baseBtn ++ (index === 2 ? highlighted : " bg-[var(--color-paper)]")}
312+              disabled={true}
313+              onMouseEnter={_evt => focusedIndex->Signal.set(2)}
314+              role="menuitem"
315+              ariaDisabled={true}
316+              tabIndex={-1}
317+            >
318               <Icons.Video className="p-[2px]" />
319               <span className="ml-2"> {"Link YT Video"->Preact.string} </span>
320               <span className="italic ml-2 text-xs"> {" (soon!)"->Preact.string} </span>
321             </button>
322-          </div>}
323+          </div>
324+        : Preact.null}
325     </div>
326   </Editor.FloatingMenu>
327 }
M client/src/ui/Menu.res
+1, -0
1@@ -52,6 +52,7 @@ module Portal = {
2     ~keepMounted: bool=?,
3     ~children: Jsx.element=?,
4     ~className: string=?,
5+    ~container: Preact.ref<nullable<Dom.element>>=?,
6   ) => Jsx.element = "Portal"
7 }
8