import "./AppMenu.css"; import { getLocalizedString } from "@olenbetong/appframe-core"; import type { DataObject } from "@olenbetong/appframe-data"; import { useData, useLoading } from "@olenbetong/appframe-react"; import clsx from "clsx"; import { useEffect, useMemo, useRef, useState } from "react"; import { AppMenuHeader } from "./AppMenuHeader.js"; import { AppMenuList } from "./AppMenuList.js"; import { AppMenuRail } from "./AppMenuRail.js"; import type { AppMenuUser } from "./AppMenuUserMenu.js"; import { type AppMenuArticle, countItems, FAVORITES, filterCategory, groupEntries, groupLabel, type Selection, toAppEntries, } from "./menuEntries.js"; import type { AppMenuCategory, AppMenuLink, AppMenuLinkGroup } from "./types.js"; import { useFavorites } from "./useFavorites.js"; export type { AppMenuArticle } from "./menuEntries.js"; export type AppMenuProps = { /** Data object returning the articles to list. See `AppMenuArticle` for the required fields. */ dataObject: DataObject; /** Whether the menu is open. The menu is a controlled component. */ open: boolean; /** Called when the menu requests to be closed (backdrop click, Escape, app selected). */ onClose: () => void; /** * Extra categories listed below the app categories in the rail, showing a * fixed set of links instead of apps. See `createDeveloperCategories`. */ categories?: AppMenuCategory[]; /** Data object over `aviw_General_SidebarUserInfo`. Enables the user menu in the header. */ userDataObject?: DataObject; /** Overrides the default user menu entries. */ userMenuItems?: AppMenuLink[]; /** * Called when the favorite toggle next to an app is activated. Without this * handler no favorite toggles are rendered. * * `id` is the version-less ArticleId. The menu updates its own state * optimistically and rolls back if the promise rejects. */ onToggleFavorite?: (id: string, favorite: boolean) => void | Promise; /** * Link shown at the start of the content header, e.g. the portal/front page, * which isn't part of the app list. Omit it when already on that page. */ homeLink?: AppMenuLink; /** Shown below the user's name. Defaults to `af.userSession.domain`. */ userSubtitle?: string; /** * Shows a highlighted "install update" button in the menu header. Pair with * `useServiceWorkerUpdate()`. */ hasUpdate?: boolean; /** Called when the install update button is pressed. */ onInstallUpdate?: () => void; id?: string; className?: string; }; /** * Two pane application menu. The left rail holds a local search field and the * list of app categories (`MenuGroup`); the right pane lists the apps in the * selected category, or every app when no category is selected. * * On narrow viewports only one pane is visible at a time, defaulting to the app * list, with a button to switch to the category list. */ export function AppMenu({ dataObject, open, onClose, categories = [], userDataObject, userMenuItems, userSubtitle, onToggleFavorite, homeLink, hasUpdate = false, onInstallUpdate, id, className, }: AppMenuProps) { let dialogRef = useRef(null); let searchRef = useRef(null); let [search, setSearch] = useState(""); let [selection, setSelection] = useState(null); let [mobilePane, setMobilePane] = useState<"apps" | "groups">("apps"); let [hasLoaded, setHasLoaded] = useState(false); /* * True once the initial category has been decided, either by the menu picking * the favorites when the apps arrive, or by the user picking one themselves. * Without it the menu would jump to the favorites group the moment the first * favorite is added. */ let [hasResolvedSelection, setHasResolvedSelection] = useState(false); let isLoading = useLoading(dataObject); let articles = useData(dataObject); let { applyOverrides, toggleFavorite } = useFavorites(onToggleFavorite); // Sync the native dialog with the `open` prop. Using showModal() puts the // dialog in the top layer so it renders above the toolbar and page content. useEffect(() => { let dialog = dialogRef.current; if (!dialog) return; if (open && !dialog.open) { dialog.showModal(); requestAnimationFrame(() => searchRef.current?.focus()); } else if (!open && dialog.open) { dialog.close(); } }, [open]); // Reset transient state whenever the menu is closed. useEffect(() => { if (!open) { setSearch(""); setSelection(null); setMobilePane("apps"); setHasResolvedSelection(false); } }, [open]); // Load the app list the first time the menu is opened. useEffect(() => { if (open && !hasLoaded) { setHasLoaded(true); dataObject.refreshDataSource(); userDataObject?.refreshDataSource(); } }, [open, hasLoaded, dataObject, userDataObject]); let entries = useMemo(() => applyOverrides(toAppEntries(articles)), [articles, applyOverrides]); let term = search.trim().toLowerCase(); let searchMatches = useMemo(() => { if (!term) return entries; return entries.filter((entry) => entry.title.toLowerCase().includes(term) || entry.id.toLowerCase().includes(term)); }, [entries, term]); let groups = useMemo(() => groupEntries(searchMatches), [searchMatches]); let visibleCategories = useMemo( () => categories.map((category) => filterCategory(category, term)).filter((category) => countItems(category) > 0), [categories, term], ); let hasFavorites = groups.length > 0 && groups[0][0] === FAVORITES; /* * The favorites are the most likely target, so select them by default. The * article list loads asynchronously, so this can't be done when opening the * menu — it has to wait until the apps have arrived. It only ever runs once * per open, so favoriting an app doesn't move the user to another group. */ useEffect(() => { if (!open || hasResolvedSelection || entries.length === 0) return; setHasResolvedSelection(true); if (hasFavorites) setSelection({ type: "group", key: FAVORITES }); }, [open, hasResolvedSelection, entries.length, hasFavorites]); // A selection that no longer has matches falls back to "all apps" so the pane // never ends up empty while results exist elsewhere. let activeSelection: Selection = null; if (selection?.type === "group" && groups.some(([group]) => group === selection.key)) activeSelection = selection; else if (selection?.type === "category" && visibleCategories.some((category) => category.id === selection.id)) { activeSelection = selection; } let activeCategoryId = activeSelection?.type === "category" ? activeSelection.id : null; let activeCategory = visibleCategories.find((category) => category.id === activeCategoryId); let activeGroupKey = activeSelection?.type === "group" ? activeSelection.key : null; // Everything the right hand pane renders, normalized to titled link groups. // The favorites are left out of the "all apps" pane, since those apps are // already listed in their own group. let paneGroups: AppMenuLinkGroup[] = useMemo(() => { if (activeCategory) return activeCategory.groups; return groups .filter(([group]) => (activeGroupKey === null ? group !== FAVORITES : group === activeGroupKey)) .map(([group, items]) => ({ title: groupLabel(group), items: items.map((entry) => ({ id: entry.id, label: entry.title, href: entry.href, isFavorite: entry.isFavorite, })), })); }, [activeCategory, activeGroupKey, groups]); let paneItemCount = paneGroups.reduce((total, group) => total + group.items.length, 0); let hasApps = searchMatches.length > 0; let railGroups = groups.map(([key, items]) => ({ key, count: items.length })); function select(next: Selection) { setSelection(next); setHasResolvedSelection(true); setMobilePane("apps"); } return ( { // Let React own the open state instead of the browser's default close. event.preventDefault(); onClose(); }} onKeyDown={(event) => { // A `type="search"` input consumes Escape to clear itself, so the // dialog's own cancel event never fires when the search has focus. if (event.key === "Escape") { event.preventDefault(); onClose(); } }} onClick={(event) => { // Clicks on the dialog element itself are backdrop clicks; anything // inside the menu is caught by the inner wrapper below. if (event.target === dialogRef.current) onClose(); }} >
{isLoading && !hasApps && !activeCategory && (

{getLocalizedString("Loading...")}

)} {!isLoading && paneItemCount === 0 && (

{getLocalizedString("No applications found")}

)} {paneItemCount > 0 && ( )} setMobilePane(mobilePane === "apps" ? "groups" : "apps")} hasUpdate={hasUpdate} onInstallUpdate={onInstallUpdate} userDataObject={userDataObject} userMenuItems={userMenuItems} userSubtitle={userSubtitle} onClose={onClose} />
); }