From a89a1917150a0aa4a8fce9e6a47eedcd85aa932a Mon Sep 17 00:00:00 2001 From: Jennifer Blumberg Date: Sun, 8 Sep 2024 15:51:52 -0700 Subject: [PATCH] New desktop header (#4598) ### Description This PR is an initial version of the desktop menu for the homepage revamp. The mobile menu is temporarily removed, so that the desktop menu can be reviewed in isolation. *This PR is not intended for merge into master until the completion of the homepage revamp* ### Notes There is still some refactoring and cleanup to do (particularly in the CSS) that will be done as part of a subsequent larger PR that includes the mobile menu. However, the functionality of the desktop can be previewed. --------- Co-authored-by: Nicholas Blumberg <41446765+nick-next@users.noreply.github.com> Co-authored-by: Nick B Co-authored-by: Pablo Noel --- server/__init__.py | 4 +- server/config/base/header_v2.json | 203 +++++++ server/templates/base.html | 3 +- static/css/core.scss | 552 ++++++++++++++++++ static/js/apps/base/components/header_bar.tsx | 130 ----- .../base/components/header_bar/header_bar.tsx | 77 +++ .../header_bar/header_bar_search.tsx | 49 ++ .../components/header_bar/header_logo.tsx | 59 ++ .../components/header_bar/menu_desktop.tsx | 155 +++++ .../header_bar/menu_desktop_rich_menu.tsx | 74 +++ .../header_bar/menu_desktop_section_group.tsx | 89 +++ .../components/header_bar/menu_mobile.tsx | 179 ++++++ .../header_bar/menu_mobile_rich_menu.tsx | 74 +++ .../header_bar/menu_rich_section_group.tsx | 95 +++ static/js/apps/base/header_app.tsx | 6 +- static/js/apps/base/main.ts | 4 +- static/js/apps/homepage/app_v2.tsx | 26 - static/js/components/nl_search_bar.tsx | 92 +-- .../nl_search_bar_header_inline.tsx | 62 ++ .../nl_search_bar/nl_search_bar_standard.tsx | 73 +++ static/js/shared/types/base.ts | 54 ++ static/webpack.config.js | 1 + 22 files changed, 1855 insertions(+), 206 deletions(-) create mode 100644 server/config/base/header_v2.json create mode 100644 static/css/core.scss delete mode 100644 static/js/apps/base/components/header_bar.tsx create mode 100644 static/js/apps/base/components/header_bar/header_bar.tsx create mode 100644 static/js/apps/base/components/header_bar/header_bar_search.tsx create mode 100644 static/js/apps/base/components/header_bar/header_logo.tsx create mode 100644 static/js/apps/base/components/header_bar/menu_desktop.tsx create mode 100644 static/js/apps/base/components/header_bar/menu_desktop_rich_menu.tsx create mode 100644 static/js/apps/base/components/header_bar/menu_desktop_section_group.tsx create mode 100644 static/js/apps/base/components/header_bar/menu_mobile.tsx create mode 100644 static/js/apps/base/components/header_bar/menu_mobile_rich_menu.tsx create mode 100644 static/js/apps/base/components/header_bar/menu_rich_section_group.tsx create mode 100644 static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx create mode 100644 static/js/components/nl_search_bar/nl_search_bar_standard.tsx diff --git a/server/__init__.py b/server/__init__.py index 7959daceb6..fc9f82caf8 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -443,8 +443,10 @@ def add_language_code(endpoint, values): @app.context_processor def inject_common_parameters(): common_variables = { + #TODO: replace HEADER_MENU with V2 'HEADER_MENU': json.dumps(libutil.get_json("config/base/header.json")), - 'FOOTER_MENU': json.dumps(libutil.get_json("config/base/footer.json")) + 'FOOTER_MENU': json.dumps(libutil.get_json("config/base/footer.json")), + 'HEADER_MENU_V2': json.dumps(libutil.get_json("config/base/header_v2.json")), } locale_variable = dict(locale=get_locale()) return {**common_variables, **locale_variable} diff --git a/server/config/base/header_v2.json b/server/config/base/header_v2.json new file mode 100644 index 0000000000..fa18989590 --- /dev/null +++ b/server/config/base/header_v2.json @@ -0,0 +1,203 @@ +[ + { + "label": "Overview", + "ariaLabel": "Data Commons overview", + "introduction": "Data Commons is an open-source platform aggregating global public data for easy exploration using natural language.", + "primarySectionGroups": [ + { + "title": "Key Features", + "items": [ + { + "title": "Large harmonized public dataset", + "description": "240 billion data points across 260K statistical variables, harmonized from governmental, inter-governmental, academic and non-profit organizations" + }, + { + "title": "Natural language interface", + "description": "Data Commons uses AI for natural language queries, making public data accessible and useful to all" + } + ] + }, + { + "title": "Build your Data Commons", + "items": [ + { + "title": "Tailor your own Data Commons", + "description": "Launch your own Data Commons and customize it with your own data to better engage your specific audience" + }, + { + "title": "Explore your data with natural language", + "description": "Ask questions in your own words and get answers directly from your data" + }, + { + "title": "Actionable Insights", + "description": "Gain actionable insights from your data in connection to global data", + "links": [ + { + "title": "Learn more & build yours today", + "url": "{place.place}" + } + ] + } + ] + } + ], + "secondarySectionGroups": [ + { + "title": "Other Data Commons", + "items": [ + { + "title": "Partners", + "description": "Featured organizations who organizations have tailored their own Data Commons to meet their specific needs and goals", + "links": [ + { + "title": "United Nations", + "url": "https://www.un.org", + "linkType": "external" + }, + { + "title": "One.org", + "url": "https://one.org", + "linkType": "external" + } + ] + } + ] + } + ] + }, + { + "label": "Tools", + "ariaLabel": "Show exploration tools", + "introduction": "Explore a variety of tools to visualize, analyze, and interact with the Data Commons knowledge graph and its extensive datasets.", + "primarySectionGroups": [ + { + "items": [ + { + "title": "Knowledge Graph", + "url": "{browser.browser_main}", + "description": "Explore what data is available and understand the graph structure" + }, + { + "title": "Statistical Variable Explorer", + "url": "{tools.stat_var}", + "description": "Explore statistical variable details including metadata and observations" + }, + { + "title": "Data Download Tool", + "url": "{tools.download}", + "description": "Download data for selected statistical variables" + } + ] + }, + { + "items": [ + { + "title": "Map Explorer", + "url": "{tools.visualization}#visType=map", + "description": "Study how a selected statistical variable can vary across geographic regions" + }, + { + "title": "Scatter Plot Explorer", + "url": "{tools.visualization}#visType=scatter", + "description": "Visualize the correlation between two statistical variables" + }, + { + "title": "Timelines Explorer", + "url": "{tools.visualization}#visType=timeline", + "description": "See trends over time for selected statistical variables" + } + ] + } + ] + }, + { + "label": "Documentation", + "ariaLabel": "Show documentation links", + "introduction": "Access in-depth tutorials, guides, and API references to unlock the full potential of Data Commons and integrate it into your projects.", + "primarySectionGroups": [ + { + "items": [ + { + "title": "Docs", + "url": "https://docs.datacommons.org", + "description": "Learn how to leverage the Data Commons unified database with comprehensive documentation, tutorials, and guides." + }, + { + "title": "API", + "url": "https://docs.datacommons.org/api", + "description": "Access a unified knowledge graph with standardized data from diverse sources using Data Commons APIs." + } + ] + }, + { + "items": [ + { + "title": "Tutorials", + "url": "https://docs.datacommons.org/tutorials", + "description": "Get familiar with the Data Commons Knowledge Graph and APIs using analysis examples in Google Colab notebooks written in Python." + }, + { + "title": "Contributions", + "url": "https://docs.datacommons.org/contributing/", + "description": "Become part of Data Commons by contributing data, tools, educational materials, or sharing your analysis and insights. Collaborate and help expand the knowledge graph!" + } + ] + } + ] + }, + { + "label": "About", + "ariaLabel": "Show about links", + "introduction": "Data Commons is an initiative from Google. Discover how Data Commons is changing data analysis. Explore diverse data, learn to use its tools through Python examples, and stay updated on the latest news and research.", + "primarySectionGroups": [ + { + "items": [ + { + "title": "Why Data Commons", + "url": "{static.about}", + "description": "Discover why Data Commons is revolutionizing data access and analysis. Learn how its unified knowledge graph empowers you to explore diverse, standardized data." + }, + { + "title": "Data Sources", + "url": "https://docs.datacommons.org/datasets/", + "description": "Get familiar with the Data Commons Knowledge Graph and APIs using analysis examples in Google Colab notebooks written in Python.", + "links": [ + { + "title": "Data Updates", + "url": "https://www.datacommons.org/rss", + "linkType": "rss" + } + ] + } + ] + }, + { + "items": [ + { + "title": "FAQ", + "url": "{static.faq}", + "description": "Find quick answers to common questions about Data Commons, its usage, data sources, and available resources." + }, + { + "title": "Blog", + "url": "https://blog.datacommons.org/", + "description": "Stay up-to-date with the latest news, updates, and insights from the Data Commons team. Explore new features, research, and educational content related to the project.", + "links": [ + { + "title": "Blog posts", + "url": "https://blog.datacommons.org/rss", + "linkType": "rss" + } + ] + } + ] + } + ] + }, + { + "label": "Feedback", + "ariaLabel": "Give feedback", + "url": "{static.feedback}", + "exposeInMobileBanner": true + } +] \ No newline at end of file diff --git a/server/templates/base.html b/server/templates/base.html index 63eba3f566..6f93c38535 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -64,6 +64,7 @@ + {% block head %} {% endblock %} {% if OVERRIDE_CSS_PATH %} @@ -95,7 +96,7 @@
{ - const visibleMenu = useMemo(() => { - return menu.map((menuItem) => ({ - ...menuItem, - subMenu: menuItem.subMenu.filter((subMenuItem) => !subMenuItem.hide), - })); - }, [menu]); - - return ( -
- -
- ); -}; - -export default HeaderBar; diff --git a/static/js/apps/base/components/header_bar/header_bar.tsx b/static/js/apps/base/components/header_bar/header_bar.tsx new file mode 100644 index 0000000000..5187f5e0e7 --- /dev/null +++ b/static/js/apps/base/components/header_bar/header_bar.tsx @@ -0,0 +1,77 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A component that renders the header on all pages via the base template. + */ + +import React, { ReactElement } from "react"; + +import { HeaderMenuV2, Labels, Routes } from "../../../../shared/types/base"; +import HeaderBarSearch from "./header_bar_search"; +import HeaderLogo from "./header_logo"; +import MenuDesktop from "./menu_desktop"; +import MenuMobile from "./menu_mobile"; + +interface HeaderBarProps { + //the name of the application (this may not be "Data Commons" in forked versions). + name: string; + //a path to the logo to be displayed in the header + logoPath: string; + //the data that will populate the header menu. + menu: HeaderMenuV2[]; + //the labels dictionary - all labels will be passed through this before being rendered. If no value exists, the dictionary will return the key that was sent. + labels: Labels; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; +} + +const HeaderBar = ({ + name, + logoPath, + menu, + labels, + routes, +}: HeaderBarProps): ReactElement => { + return ( +
+ +
+ ); +}; + +export default HeaderBar; diff --git a/static/js/apps/base/components/header_bar/header_bar_search.tsx b/static/js/apps/base/components/header_bar/header_bar_search.tsx new file mode 100644 index 0000000000..d1752c1349 --- /dev/null +++ b/static/js/apps/base/components/header_bar/header_bar_search.tsx @@ -0,0 +1,49 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* A wrapping component to render the header bar version of the search */ + +import React, { ReactElement } from "react"; + +import { NlSearchBar } from "../../../../components/nl_search_bar"; +import { + GA_EVENT_NL_SEARCH, + GA_PARAM_QUERY, + GA_PARAM_SOURCE, + GA_VALUE_SEARCH_SOURCE_HOMEPAGE, + triggerGAEvent, +} from "../../../../shared/ga_events"; + +const HeaderBarSearch = (): ReactElement => { + return ( + { + triggerGAEvent(GA_EVENT_NL_SEARCH, { + [GA_PARAM_QUERY]: q, + [GA_PARAM_SOURCE]: GA_VALUE_SEARCH_SOURCE_HOMEPAGE, + }); + window.location.href = `/explore#q=${encodeURIComponent(q)}`; + }} + placeholder={"Enter a question to explore"} + initialValue={""} + shouldAutoFocus={false} + /> + ); +}; + +export default HeaderBarSearch; diff --git a/static/js/apps/base/components/header_bar/header_logo.tsx b/static/js/apps/base/components/header_bar/header_logo.tsx new file mode 100644 index 0000000000..b262fe3b01 --- /dev/null +++ b/static/js/apps/base/components/header_bar/header_logo.tsx @@ -0,0 +1,59 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The branding logo that appears in the header. + */ + +import React, { ReactElement } from "react"; + +import { Labels, Routes } from "../../../../shared/types/base"; + +interface HeaderLogoProps { + //the name of the application (this may not be "Data Commons" in forked versions). + name: string; + //a path to the logo to be displayed in the header + logoPath: string; + //the labels dictionary - all labels will be passed through this before being rendered. If no value exists, the dictionary will return the key that was sent. + labels: Labels; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; +} + +const HeaderLogo = ({ + name, + logoPath, + labels, + routes, +}: HeaderLogoProps): ReactElement => { + return ( +
+ {logoPath && ( + + )} + {name} +
+ ); +}; + +export default HeaderLogo; diff --git a/static/js/apps/base/components/header_bar/menu_desktop.tsx b/static/js/apps/base/components/header_bar/menu_desktop.tsx new file mode 100644 index 0000000000..55d317fe98 --- /dev/null +++ b/static/js/apps/base/components/header_bar/menu_desktop.tsx @@ -0,0 +1,155 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* The content of the desktop version of the header */ + +import React, { ReactElement, useEffect, useRef, useState } from "react"; + +import { HeaderMenuV2, Labels, Routes } from "../../../../shared/types/base"; +import { resolveHref, slugify } from "../../utilities/utilities"; +import MenuDesktopRichMenu from "./menu_desktop_rich_menu"; + +interface MenuDesktopProps { + //the data that will populate the header menu. + menu: HeaderMenuV2[]; + //the labels dictionary - all labels will be passed through this before being rendered. If no value exists, the dictionary will return the key that was sent. + labels: Labels; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; +} + +//TODO: verify the desired length of the timer +const MENU_CLOSE_TIMER = 250; + +const MenuDesktop = ({ + menu, + labels, + routes, +}: MenuDesktopProps): ReactElement => { + const [openMenu, setOpenMenu] = useState(null); + const [panelHeight, setPanelHeight] = useState(0); + const submenuRefs = useRef>([]); + const closeMenuTimer = useRef(null); + + const resetMenu = (): void => { + setOpenMenu(null); + setPanelHeight(0); + }; + + const toggleMenu = (index: number): void => { + openMenu === index ? resetMenu() : setOpenMenu(index); + }; + + const itemMenuTouch = (e: React.TouchEvent, index: number): void => { + e.preventDefault(); + toggleMenu(index); + }; + + const handleOpenMenu = (index: number): void => { + setOpenMenu(index); + }; + + const handleMouseLeave = (): void => { + closeMenuTimer.current = setTimeout(() => { + resetMenu(); + }, MENU_CLOSE_TIMER); + }; + + const handleMouseEnter = (): void => { + if (closeMenuTimer.current) { + clearTimeout(closeMenuTimer.current); + } + }; + + useEffect(() => { + if (openMenu !== null && submenuRefs.current[openMenu]) { + setPanelHeight(submenuRefs.current[openMenu]?.scrollHeight || 0); + } + }, [openMenu]); + + useEffect(() => { + const handleScroll = (): void => { + if (openMenu !== null) { + resetMenu(); + } + }; + window.addEventListener("scroll", handleScroll); + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, [openMenu]); + + return ( +
+
    + {menu.map((menuItem, index) => ( +
  • !menuItem.url && handleOpenMenu(index)} + onClick={(): void => !menuItem.url && toggleMenu(index)} + onTouchEnd={(e): void => { + if (!menuItem.url) itemMenuTouch(e, index); + }} + > + {menuItem.url ? ( + + {labels[menuItem.label]} + + ) : ( + <> + {labels[menuItem.label]} + + keyboard_arrow_down + +
    { + submenuRefs.current[index] = el; + }} + className="rich-menu-container" + aria-labelledby={slugify(`nav-${menuItem.label}-dropdown`)} + style={{ + maxHeight: openMenu === index ? `${panelHeight}px` : 0, + }} + > + +
    + + )} +
  • + ))} +
+
+
+ ); +}; + +export default MenuDesktop; diff --git a/static/js/apps/base/components/header_bar/menu_desktop_rich_menu.tsx b/static/js/apps/base/components/header_bar/menu_desktop_rich_menu.tsx new file mode 100644 index 0000000000..221e15b08d --- /dev/null +++ b/static/js/apps/base/components/header_bar/menu_desktop_rich_menu.tsx @@ -0,0 +1,74 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* A component to render the rich menu drop-down for the desktop menu */ + +//TODO: Look into folding this into the same component as the mobile version, pending no changes to the structure. + +import React, { ReactElement } from "react"; + +import { HeaderMenuV2, Routes } from "../../../../shared/types/base"; +import MenuRichSectionGroup from "./menu_rich_section_group"; + +interface MenuDesktopRichMenuProps { + //the top level header item that will render in the open rich menu container + menuItem: HeaderMenuV2; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; + //a flag to indicate whether the menu is open. + open: boolean; +} + +const MenuDesktopRichMenu = ({ + menuItem, + routes, + open, +}: MenuDesktopRichMenuProps): ReactElement => { + return ( +
+
+

{menuItem.label}

+ {menuItem.introduction &&

{menuItem.introduction}

} +
+ {menuItem.primarySectionGroups?.length > 0 && ( +
+ {menuItem.primarySectionGroups.map((primarySectionGroup, index) => ( + + ))} +
+ )} + {menuItem.secondarySectionGroups?.length > 0 && ( +
+ {menuItem.secondarySectionGroups.map( + (secondarySectionGroup, index) => ( + + ) + )} +
+ )} +
+ ); +}; + +export default MenuDesktopRichMenu; diff --git a/static/js/apps/base/components/header_bar/menu_desktop_section_group.tsx b/static/js/apps/base/components/header_bar/menu_desktop_section_group.tsx new file mode 100644 index 0000000000..007bd82fbc --- /dev/null +++ b/static/js/apps/base/components/header_bar/menu_desktop_section_group.tsx @@ -0,0 +1,89 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* A component to render a section group of the rich menu */ + +import React, { ReactElement } from "react"; + +import { HeaderMenuGroup, Routes } from "../../../../shared/types/base"; +import { resolveHref } from "../../utilities/utilities"; + +interface MenuDesktopSectionGroupProps { + //the menu group to be rendered inside a particular location in the rich menu + menuGroup: HeaderMenuGroup; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; +} + +const MenuDesktopSectionGroup = ({ + menuGroup, + routes, +}: MenuDesktopSectionGroupProps): ReactElement => { + return ( +
+ {menuGroup.title &&

{menuGroup.title}

} + {menuGroup.items.map((item, index) => ( +
+ {item.title && item.url ? ( +
+ + {item.linkType === "external" && ( + arrow_outward + )} + {item.linkType === "rss" && rss_feed} + {item.title} + +
+ ) : ( +
{item.title}
+ )} + + {item.description &&

{item.description}

} + + {item.links?.length > 0 && ( +
+ {item.links.map((link, index) => ( +
+ {link.linkType === "rss" ? ( + <> + + rss_feed + RSS Feed + + {link.title &&

• {link.title}

} + + ) : ( + + {link.linkType === "external" && ( + arrow_outward + )} + {link.title} + + )} +
+ ))} +
+ )} +
+ ))} +
+ ); +}; + +export default MenuDesktopSectionGroup; diff --git a/static/js/apps/base/components/header_bar/menu_mobile.tsx b/static/js/apps/base/components/header_bar/menu_mobile.tsx new file mode 100644 index 0000000000..1b2d5dbcc1 --- /dev/null +++ b/static/js/apps/base/components/header_bar/menu_mobile.tsx @@ -0,0 +1,179 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* The content of the mobile version of the header */ + +import React, { + ReactElement, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { HeaderMenuV2, Labels, Routes } from "../../../../shared/types/base"; +import { resolveHref } from "../../utilities/utilities"; +import MenuMobileRichMenu from "./menu_mobile_rich_menu"; + +interface MenuMobileProps { + //the data that will populate the header menu. + menu: HeaderMenuV2[]; + //the labels dictionary - all labels will be passed through this before being rendered. If no value exists, the dictionary will return the key that was sent. + labels: Labels; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; +} + +const MenuMobile = ({ + menu, + labels, + routes, +}: MenuMobileProps): ReactElement => { + const [open, setOpen] = useState(false); + const [drawerWidth, setDrawerWidth] = useState(0); + const [selectedPrimaryItemIndex, setSelectedPrimaryItemIndex] = useState< + number | null + >(null); + const drawerRef = useRef(null); + + const toggleDrawer = (): void => { + setOpen(!open); + setSelectedPrimaryItemIndex(null); + }; + + const handlePrimaryItemClick = (primaryItemIndex: number): void => { + setSelectedPrimaryItemIndex(primaryItemIndex); + }; + + const handleBackClick = (): void => { + setSelectedPrimaryItemIndex(null); + }; + + const selectedPrimaryItem = useMemo( + () => + selectedPrimaryItemIndex !== null ? menu[selectedPrimaryItemIndex] : null, + [selectedPrimaryItemIndex, menu] + ); + + useEffect(() => { + if (open && drawerRef.current) { + setDrawerWidth(drawerRef.current.scrollWidth / 2); + } else { + setDrawerWidth(0); + } + }, [open]); + + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + const headerLinks = useMemo( + () => menu.filter((menuItem) => menuItem.exposeInMobileBanner), + [menu] + ); + + return ( +
+
+ {headerLinks.map((menuItem) => ( + + {labels[menuItem.label]} + + ))} +
+ + +
+ +
+
+
+ + {selectedPrimaryItemIndex !== null && ( + + )} +
+ +
+
+
    + {menu.map((item, index) => ( +
  • + {item.url ? ( + + {labels[item.label]} + + ) : ( + <> + + + )} +
  • + ))} +
+
+ +
+ +
+
+
+
+
+ ); +}; + +export default MenuMobile; diff --git a/static/js/apps/base/components/header_bar/menu_mobile_rich_menu.tsx b/static/js/apps/base/components/header_bar/menu_mobile_rich_menu.tsx new file mode 100644 index 0000000000..6b4a3bfc35 --- /dev/null +++ b/static/js/apps/base/components/header_bar/menu_mobile_rich_menu.tsx @@ -0,0 +1,74 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* A component to render the rich menu drop-down for the mobile menu */ + +import React, { ReactElement } from "react"; + +import { HeaderMenuV2, Labels, Routes } from "../../../../shared/types/base"; +import MenuRichSectionGroup from "./menu_rich_section_group"; + +interface MenuMobileRichMenuProps { + //the menu item for which we are rendering the rich menu + menuItem: HeaderMenuV2 | null; + //the labels dictionary - all labels will be passed through this before being rendered. If no value exists, the dictionary will return the key that was sent. + labels: Labels; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; +} + +const MenuMobileRichMenu = ({ + menuItem, + labels, + routes, +}: MenuMobileRichMenuProps): ReactElement => { + if (!menuItem) return null; + + return ( + <> +
+

{labels[menuItem.label]}

+ {menuItem.introduction &&

{menuItem.introduction}

} +
+ {menuItem.primarySectionGroups?.length > 0 && ( +
+ {menuItem.primarySectionGroups.map((primarySectionGroup, index) => ( + + ))} +
+ )} + {menuItem.secondarySectionGroups?.length > 0 && ( +
+ {menuItem.secondarySectionGroups.map( + (secondarySectionGroup, index) => ( + + ) + )} +
+ )} + + ); +}; + +export default MenuMobileRichMenu; diff --git a/static/js/apps/base/components/header_bar/menu_rich_section_group.tsx b/static/js/apps/base/components/header_bar/menu_rich_section_group.tsx new file mode 100644 index 0000000000..6e792e61f2 --- /dev/null +++ b/static/js/apps/base/components/header_bar/menu_rich_section_group.tsx @@ -0,0 +1,95 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* A component to render a section group of the rich menu (both mobile and desktop) */ + +import React, { ReactElement } from "react"; + +import { HeaderMenuGroup, Routes } from "../../../../shared/types/base"; +import { resolveHref } from "../../utilities/utilities"; + +interface MenuRichSectionGroupProps { + //the menu group to be rendered inside a particular location in the rich menu + menuGroup: HeaderMenuGroup; + //the routes dictionary - this is used to convert routes to resolved urls + routes: Routes; +} + +const MenuRichSectionGroup = ({ + menuGroup, + routes, +}: MenuRichSectionGroupProps): ReactElement => { + return ( +
+ {menuGroup.title &&

{menuGroup.title}

} + {menuGroup.items.map((item, index) => ( +
+ {item.title && item.url ? ( +
+ + {item.linkType === "external" && ( + arrow_outward + )} + {item.linkType === "rss" && ( + rss_feed + )} + {item.title} + +
+ ) : ( +
{item.title}
+ )} + + {item.description &&

{item.description}

} + + {item.links?.length > 0 && ( +
+ {item.links.map((link, index) => ( +
+ {link.linkType === "rss" ? ( + <> + + + rss_feed + + RSS Feed + + {link.title &&

• {link.title}

} + + ) : ( + + {link.linkType === "external" && ( + + arrow_outward + + )} + {link.title} + + )} +
+ ))} +
+ )} +
+ ))} +
+ ); +}; + +export default MenuRichSectionGroup; diff --git a/static/js/apps/base/header_app.tsx b/static/js/apps/base/header_app.tsx index 841f170d32..267192de18 100644 --- a/static/js/apps/base/header_app.tsx +++ b/static/js/apps/base/header_app.tsx @@ -20,8 +20,8 @@ import React, { ReactElement } from "react"; -import { HeaderMenu, Labels, Routes } from "../../shared/types/base"; -import HeaderBar from "./components/header_bar"; +import { HeaderMenuV2, Labels, Routes } from "../../shared/types/base"; +import HeaderBar from "./components/header_bar/header_bar"; interface HeaderAppProps { //the name of the application (this may not be "Data Commons" in forked versions). @@ -29,7 +29,7 @@ interface HeaderAppProps { //a path to the logo to be displayed in the header logoPath: string; //the data that will populate the header menu. - headerMenu: HeaderMenu[]; + headerMenu: HeaderMenuV2[]; //the labels dictionary - all labels will be passed through this before being rendered. If no value exists, the dictionary will return the key that was sent. labels: Labels; //the routes dictionary - this is used to convert routes to resolved urls diff --git a/static/js/apps/base/main.ts b/static/js/apps/base/main.ts index c5d0298b04..d527167e17 100644 --- a/static/js/apps/base/main.ts +++ b/static/js/apps/base/main.ts @@ -22,7 +22,7 @@ import React from "react"; import ReactDOM from "react-dom"; import { loadLocaleData } from "../../i18n/i18n"; -import { FooterMenu, HeaderMenu } from "../../shared/types/base"; +import { FooterMenu, HeaderMenuV2 } from "../../shared/types/base"; import { FooterApp } from "./footer_app"; import { HeaderApp } from "./header_app"; import { extractLabels, extractRoutes } from "./utilities/utilities"; @@ -40,7 +40,7 @@ function renderPage(): void { const headerMenu = JSON.parse( metadataContainer.dataset.header - ) as HeaderMenu[]; + ) as HeaderMenuV2[]; const footerMenu = JSON.parse( metadataContainer.dataset.footer ) as FooterMenu[]; diff --git a/static/js/apps/homepage/app_v2.tsx b/static/js/apps/homepage/app_v2.tsx index 762ca97dac..ca6aff8f0d 100644 --- a/static/js/apps/homepage/app_v2.tsx +++ b/static/js/apps/homepage/app_v2.tsx @@ -22,14 +22,6 @@ import React, { ReactElement } from "react"; -import { NlSearchBar } from "../../components/nl_search_bar"; -import { - GA_EVENT_NL_SEARCH, - GA_PARAM_QUERY, - GA_PARAM_SOURCE, - GA_VALUE_SEARCH_SOURCE_HOMEPAGE, - triggerGAEvent, -} from "../../shared/ga_events"; import { Routes } from "../../shared/types/base"; import { Partner, Topic } from "../../shared/types/homepage"; import DataSize from "./components/data_size"; @@ -54,24 +46,6 @@ interface AppProps { export function App({ topics, partners, routes }: AppProps): ReactElement { return ( <> -
-
- { - triggerGAEvent(GA_EVENT_NL_SEARCH, { - [GA_PARAM_QUERY]: q, - [GA_PARAM_SOURCE]: GA_VALUE_SEARCH_SOURCE_HOMEPAGE, - }); - window.location.href = `/explore#q=${encodeURIComponent(q)}`; - }} - placeholder={"Enter a question to explore"} - initialValue={""} - shouldAutoFocus={false} - /> -
-
- diff --git a/static/js/components/nl_search_bar.tsx b/static/js/components/nl_search_bar.tsx index d38e0ffbb8..08e5460b04 100644 --- a/static/js/components/nl_search_bar.tsx +++ b/static/js/components/nl_search_bar.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2023 Google LLC + * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,10 +18,13 @@ * Component for the search bar used for all NL queries. */ -import React, { useEffect, useState } from "react"; -import { Input, InputGroup } from "reactstrap"; +import React, { ReactElement, useEffect, useState } from "react"; + +import NlSearchBarHeaderInline from "./nl_search_bar/nl_search_bar_header_inline"; +import NlSearchBarStandard from "./nl_search_bar/nl_search_bar_standard"; interface NlSearchBarPropType { + variant?: "standard" | "header-inline"; allowEmptySearch?: boolean; inputId: string; onSearch: (q: string) => void; @@ -31,7 +34,27 @@ interface NlSearchBarPropType { feedbackLink?: string; } -export function NlSearchBar(props: NlSearchBarPropType): JSX.Element { +// an interface for the implementation of variants of the natural language search, passing shared common components +export interface NlSearchBarImplementationProps { + //the value currently in state representing the entered search term + value: string; + //a boolean flag for invalid search entries + invalid: boolean; + //the placeholder text + placeholder: string; + //the id of the input + inputId: string; + //the change event (used to trigger appropriate state changes in this parent) + onChange: (e: React.ChangeEvent) => void; + //a function to be called once a search is run + onSearch: () => void; + //the autofocus attribute of the input will be set to shouldAutoFocus + shouldAutoFocus?: boolean; + //an optional feedback link + feedbackLink?: string; +} + +export function NlSearchBar(props: NlSearchBarPropType): ReactElement { const [invalid, setInvalid] = useState(false); const [value, setValue] = useState(""); useEffect(() => { @@ -39,45 +62,6 @@ export function NlSearchBar(props: NlSearchBarPropType): JSX.Element { setInvalid(false); }, [props.initialValue]); - return ( -
-
-
Early preview
- {props.feedbackLink && ( - <> - | - - - )} -
-
-
- - { - setValue(e.target.value); - setInvalid(false); - }} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - className="pac-target-input search-input-text" - autoFocus={props.shouldAutoFocus} - autoComplete="off" - > -
-
-
-
-
- ); - function handleSearch(): void { if (!props.allowEmptySearch && !value) { setInvalid(true); @@ -86,4 +70,26 @@ export function NlSearchBar(props: NlSearchBarPropType): JSX.Element { props.onSearch(value); } + + const commonProps: NlSearchBarImplementationProps = { + value, + invalid, + placeholder: props.placeholder, + inputId: props.inputId, + onChange: (e: React.ChangeEvent) => { + setValue(e.target.value); + setInvalid(false); + }, + onSearch: handleSearch, + shouldAutoFocus: props.shouldAutoFocus, + feedbackLink: props.feedbackLink, + }; + + const variant = props.variant || "standard"; + + return variant === "header-inline" ? ( + + ) : ( + + ); } diff --git a/static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx b/static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx new file mode 100644 index 0000000000..ff6285f202 --- /dev/null +++ b/static/js/components/nl_search_bar/nl_search_bar_header_inline.tsx @@ -0,0 +1,62 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Inline-header version of the NL Search Component - used in Version 2 of the header + */ + +import React, { ReactElement } from "react"; +import { Input, InputGroup } from "reactstrap"; + +import { NlSearchBarImplementationProps } from "../nl_search_bar"; + +const NlSearchBarHeaderInline = ({ + value, + invalid, + placeholder, + inputId, + onChange, + onSearch, + shouldAutoFocus, +}: NlSearchBarImplementationProps): ReactElement => { + return ( +
+
+
+ + search + e.key === "Enter" && onSearch()} + className="pac-target-input search-input-text" + autoFocus={shouldAutoFocus} + autoComplete="off" + > +
+ arrow_forward +
+
+
+
+
+ ); +}; + +export default NlSearchBarHeaderInline; diff --git a/static/js/components/nl_search_bar/nl_search_bar_standard.tsx b/static/js/components/nl_search_bar/nl_search_bar_standard.tsx new file mode 100644 index 0000000000..625caa52b8 --- /dev/null +++ b/static/js/components/nl_search_bar/nl_search_bar_standard.tsx @@ -0,0 +1,73 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Standard version of the NL Search Component - used as a stand-alone component in the body of a page. + */ + +import React, { ReactElement } from "react"; +import { Input, InputGroup } from "reactstrap"; + +import { NlSearchBarImplementationProps } from "../nl_search_bar"; + +const NlSearchBarStandard = ({ + value, + invalid, + placeholder, + inputId, + onChange, + onSearch, + feedbackLink, + shouldAutoFocus, +}: NlSearchBarImplementationProps): ReactElement => { + return ( +
+
+
Early preview
+ {feedbackLink && ( + <> + | + + + )} +
+
+
+ + e.key === "Enter" && onSearch()} + className="pac-target-input search-input-text" + autoFocus={shouldAutoFocus} + autoComplete="off" + > +
+
+
+
+
+ ); +}; + +export default NlSearchBarStandard; diff --git a/static/js/shared/types/base.ts b/static/js/shared/types/base.ts index b130a58c2c..9d6bb4acb4 100644 --- a/static/js/shared/types/base.ts +++ b/static/js/shared/types/base.ts @@ -56,6 +56,60 @@ interface FooterSubMenu { hide?: boolean; } +// The top level of the header menu +export interface HeaderMenuV2 { + //the label that displays in the top level menu item of the header + label: string; + //an optional url - if this is supplied, any subsequent information will be ignored as the rich menu cannot be clicked + url?: string; + //the aria-label attribute for that header + ariaLabel: string; + //text that displays in the introduction section of the desktop menu + introduction?: string; + //the content of the primary column (the column directly next to the introduction column; + primarySectionGroups?: HeaderMenuGroup[]; + //the content of the secondary column (the smaller column to the right of the introduction column; + secondarySectionGroups?: HeaderMenuGroup[]; + //if set to true, will be exposed outside the mobile toggle button and on the banner itself - this should be used with consideration for available space + exposeInMobileBanner?: boolean; +} + +// a subsection of the header menu. These contain an optional title and a list of content items. +export interface HeaderMenuGroup { + id: string; + //an optional title for the column group + title?: string; + //a list of the content items associated with each column + items: HeaderMenuItem[]; +} + +// a header group content item - each group is populated by these. +export interface HeaderMenuItem { + //the title of the header menu item - this can be a link if a link is given, otherwise a regular header + title?: string; + //the url of the header menu item - giving one will make the title clickable + url?: string; + //the type of link: internal, external or rss - this determines how the link is decorated. + linkType?: LinkType; + //a description of the item. This will go below the link, and is optional. + description?: string; + //an optional list of links that appear below the description. + links?: HeaderMenuItemLink[]; +} + +// a header menu item link - these links will appear below the header item description if links are given. +export interface HeaderMenuItemLink { + //the title of a header menu item link + title: string; + //the type of link: internal, external or rss - this determines how the link is decorated. + linkType: LinkType; + //the url of the link + url: string; +} + +//a union of possible link types for determining how links are decorated. +export type LinkType = "internal" | "external" | "rss"; + //A dictionary of routes. These map route names, such as "tools.visualization" to the resolved route. //These are implemented as a proxy object that will return the text of the key if the key is not found. //This is to allow the flexibility using both urls and routes in source data such as JSON that will be passed through the object. diff --git a/static/webpack.config.js b/static/webpack.config.js index 6f6caac016..df6f47d89e 100644 --- a/static/webpack.config.js +++ b/static/webpack.config.js @@ -46,6 +46,7 @@ const config = { mcf_playground: __dirname + "/js/mcf_playground.js", base: [ __dirname + "/js/apps/base/main.ts", + __dirname + "/css/core.scss", ], place: [ __dirname + "/js/place/place.ts",