import { getLocalizedString, getUserImage } from "@olenbetong/appframe-core"; import type { DataObject } from "@olenbetong/appframe-data"; import { useData } from "@olenbetong/appframe-react"; import { useEffect, useId, type KeyboardEvent, useRef, useState } from "react"; import type { AppMenuLink } from "./types.js"; /** Fields required from `aviw_Personnel_Employees_Lookup`. */ export type AppMenuUser = { PrimKey: string; PersonID: string; FirstName: string | null; LastName: string | null; Fullname: string | null; ImageHash: number | null; }; /** Same entries as the sidebar user disclosure menu. */ export function defaultUserMenuItems(): AppMenuLink[] { return [ { label: getLocalizedString("My profile"), href: "/edit-profile" }, { label: getLocalizedString("Change domain"), href: "/change-domain" }, { label: getLocalizedString("Change theme"), href: "/change-theme" }, { label: getLocalizedString("Change password"), href: "/change-password" }, { label: getLocalizedString("Troubleshooting"), href: "/diagnostics", separatorBefore: true }, { label: getLocalizedString("Log out"), href: "/logout", separatorBefore: true }, ]; } export type AppMenuUserMenuProps = { /** * Data object over `aviw_Personnel_Employees_Lookup` filtered to the current * user (`PersonID = af.userSession.login`). See `AppMenuUser`. */ dataObject: DataObject; /** Overrides the default menu entries. */ items?: AppMenuLink[]; /** Shown below the name. Defaults to `af.userSession.domain`. */ subtitle?: string; }; /** * Disclosure menu showing the signed in user's photo and full name, with the * account related links from the old sidebar. * * Users without an employee record still get the menu, using the name from the * Appframe user session, so log out and password changes stay reachable. */ export function AppMenuUserMenu({ dataObject, items, subtitle }: AppMenuUserMenuProps) { let containerRef = useRef(null); let triggerRef = useRef(null); let listRef = useRef(null); let panelId = useId(); let [isOpen, setIsOpen] = useState(false); let [hasPhotoError, setHasPhotoError] = useState(false); let [user] = useData(dataObject); let session = globalThis.af?.userSession; let menuItems = items ?? defaultUserMenuItems(); // Close when the pointer goes down outside the disclosure. useEffect(() => { if (!isOpen) return; function onDocumentPointerDown(event: PointerEvent) { if (!containerRef.current?.contains(event.target as Node)) setIsOpen(false); } document.addEventListener("pointerdown", onDocumentPointerDown); return () => document.removeEventListener("pointerdown", onDocumentPointerDown); }, [isOpen]); function menuLinks() { return [...(listRef.current?.querySelectorAll("a, button") ?? [])]; } /** Focus a menu entry once the list has been shown. */ function focusEntry(position: "first" | "last") { requestAnimationFrame(() => { let links = menuLinks(); (position === "first" ? links[0] : links[links.length - 1])?.focus(); }); } function close({ focusTrigger = true } = {}) { setIsOpen(false); if (focusTrigger) triggerRef.current?.focus(); } function onTriggerKeyDown(event: KeyboardEvent) { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setIsOpen(true); focusEntry(event.key === "ArrowDown" ? "first" : "last"); } } function onListKeyDown(event: KeyboardEvent) { let links = menuLinks(); let index = links.indexOf(document.activeElement as HTMLElement); if (index < 0 || links.length === 0) return; let next: number; if (event.key === "ArrowDown") next = (index + 1) % links.length; else if (event.key === "ArrowUp") next = (index - 1 + links.length) % links.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = links.length - 1; else return; event.preventDefault(); event.stopPropagation(); links[next].focus(); } let fullName = user?.Fullname?.trim() || [user?.FirstName, user?.LastName].filter(Boolean).join(" ").trim() || session?.name || session?.login || getLocalizedString("Account"); let photo = user && !hasPhotoError ? getUserImage(user.PrimKey, user.PersonID, user.ImageHash, "sm") : ""; // The domain is set server side when the session is created. let domain = subtitle ?? session?.domain ?? ""; return (
{ // Don't let Escape bubble to the dialog and close the whole menu. if (event.key === "Escape" && isOpen) { event.stopPropagation(); event.preventDefault(); close(); } }} onBlur={(event) => { // Tabbing (or clicking) out of the disclosure closes it. if (isOpen && !event.currentTarget.contains(event.relatedTarget)) close({ focusTrigger: false }); }} >
); }