Skip to main content

Menu

A navigation component that renders a category hierarchy fetched from the Propeller GraphQL API. It supports two layout styles -- dropdown-vertical (nested flyout columns on hover) and jumbotron (full-width mega-menu panel) -- plus a mobile accordion view that shows automatically on smaller screens.

The component handles its own data fetching, caching, and localization internally. User interactions are delegated to the parent via the onMenuItemClick callback for SPA-style routing.


Usage

Basic dropdown menu in a header

import Menu from '@/components/propeller/Menu';
import { graphqlClient } from '@/lib/api';
import { config } from '@/data/config';
import { useRouter } from 'next/navigation';

function Header() {
const router = useRouter();

return (
<Menu
graphqlClient={graphqlClient}
categoryId={17}
language="NL"
configuration={config}
onMenuItemClick={(category) => {
router.push(config.urls.getCategoryUrl(category, 'NL'));
}}
/>
);
}

Jumbotron / mega-menu style

<Menu
graphqlClient={graphqlClient}
categoryId={17}
language="NL"
menuStyle="jumbotron"
configuration={config}
onMenuItemClick={(category) =>
router.push(config.urls.getCategoryUrl(category, 'NL'))
}
/>

With authenticated user (enables user-specific cache buckets)

<Menu
graphqlClient={graphqlClient}
categoryId={17}
language="NL"
user={authState.user}
configuration={config}
onMenuItemClick={(category) => {
router.push(config.urls.getCategoryUrl(category, 'NL'));
}}
/>

Always-mounted pattern (prevents re-fetch on toggle)

The recommended pattern keeps the menu always mounted in the DOM and toggles visibility with CSS. This avoids re-fetching the category tree every time the menu opens.

const [showMenu, setShowMenu] = useState(false);

<div
ref={menuRef}
onMouseLeave={() => setShowMenu(false)}
>
<button onMouseEnter={() => setShowMenu(true)}>
Browse Categories
</button>

<div className={showMenu
? "visible opacity-100"
: "invisible opacity-0 pointer-events-none h-0 overflow-hidden"
}>
<Menu
graphqlClient={graphqlClient}
categoryId={17}
language={language}
user={user}
configuration={config}
onMenuItemClick={(category) => {
setShowMenu(false);
router.push(config.urls.getCategoryUrl(category, language));
}}
/>
</div>
</div>

Custom labels (Dutch localization)

<Menu
graphqlClient={graphqlClient}
categoryId={17}
language="NL"
configuration={config}
labels={{
loading: 'Menu laden...',
error: 'Menu kon niet geladen worden',
empty: 'Geen categorieën gevonden',
}}
onMenuItemClick={(category) =>
router.push(config.urls.getCategoryUrl(category, 'NL'))
}
/>

Deeper hierarchy with custom styling

<Menu
graphqlClient={graphqlClient}
categoryId={17}
language="EN"
depth={4}
menuClass="border rounded-lg bg-white shadow-sm"
className="w-72"
configuration={config}
onMenuItemClick={(category) =>
router.push(config.urls.getCategoryUrl(category, 'EN'))
}
/>

Configuration

Data & fetching

PropTypeDefaultDescription
graphqlClientGraphQLClientrequiredInitialized Propeller SDK GraphQL client used to fetch category data
categoryIdnumberrequiredRoot category ID -- the top of the menu tree
languagestringrequiredLanguage code for localized category names and slugs (e.g. 'NL', 'EN')
depthnumber3Maximum nesting depth of the category hierarchy
userContact | Customer | nullnullAuthenticated user. When the user changes (login/logout), the cache key changes and the menu re-fetches
configurationanyundefinedApp configuration object (from @/data/config). Must include urls.getCategoryUrl(category, language) for URL generation

Appearance

PropTypeDefaultDescription
menuStylestring'dropdown-vertical'Layout variant: 'dropdown-vertical' or 'jumbotron'
menuClassstring--CSS class applied to the inner <nav> element
classNamestring--CSS class applied to the root <div> wrapper
menuLinkFormatstring--URL pattern with {categoryId} and {slug} placeholders (used as fallback if configuration is not provided)

Callbacks & labels

PropTypeDefaultDescription
onMenuItemClick(category: Category) => voidrequiredCalled when a menu link is clicked. The default <a> navigation is prevented so the parent controls routing
labelsRecord<string, string>--Override UI strings. Keys: loading, error, empty

Labels

KeyDefaultShown when
loading'Loading menu...'Categories are being fetched
error'Failed to load menu'The GraphQL request failed
empty'No categories found'The root category has no visible subcategories

Layout styles

A vertical list of top-level categories. On hover, subcategories appear as flyout columns to the right, up to 3 levels deep. Desktop only -- on mobile the component renders an accordion instead.

+-----------------+
| Computers | -> +-----------------+
| Peripherals | | Keyboards | -> +-----------------+
| Networking | | Mice | | Wireless |
| ... | | Monitors | | Wired |
+-----------------+ | ... | | Ergonomic |
+-----------------+ +-----------------+

jumbotron

Top-level categories render as horizontal tabs. The hovered tab reveals a full-width panel with subcategories in a responsive grid (2-4 columns). Level 3 items appear as lists beneath each level 2 heading.

[ Computers ] [ Peripherals ] [ Networking ] ...
+------------------------------------------------------+
| Keyboards Mice Monitors |
| - Wireless - Gaming - 4K |
| - Mechanical - Ergonomic - Ultrawide |
| - Compact - Trackballs - Curved |
+------------------------------------------------------+

Mobile accordion (automatic)

On screens narrower than the md breakpoint, both styles are hidden and a vertical accordion is shown instead. Tapping a category name navigates to it; tapping the chevron expands/collapses its children.


Behavior

Caching

The component always caches the fetched category tree in localStorage to avoid unnecessary API calls on every render.

  • Cache key format: propeller_menu_{categoryId}_{language} for anonymous users, or propeller_menu_{categoryId}_{language}_c{contactId} / propeller_menu_{categoryId}_{language}_u{customerId} for authenticated users.
  • TTL: 12 hours (43,200,000 ms). Expired entries are removed on the next fetch attempt.
  • Quota safety: localStorage.setItem is wrapped in a try/catch so the component degrades gracefully if storage is full.

Auth-based cache invalidation

Because the cache key includes the user identifier, logging in or out automatically produces a different cache key. This means:

  • Anonymous and authenticated users never share cached data.
  • Switching between user accounts also uses separate cache buckets.
  • No explicit cache-clearing logic is needed on auth transitions -- the user prop change triggers a re-fetch against the new cache key.

Language support

  • Category names and slugs are fetched using the language variable in the GraphQL query.
  • Changing the language prop triggers a re-fetch (and uses a different cache key).
  • The component tries to find a localized string matching the requested language; if not found, it falls back to the first available translation.

Re-fetch triggers

The menu re-fetches when any of these props change:

  • graphqlClient
  • categoryId
  • language
  • user

On each fetch, the cache is checked first. If a valid (non-expired) cached entry exists for the current cache key, the API call is skipped entirely.

Hover state

  • dropdown-vertical: Tracks hovered L1 and L2 category IDs to show/hide flyout columns. Hovering a new L1 category resets the L2 hover state.
  • jumbotron: Tracks hovered L1 to display the mega panel. L2 and L3 categories are always visible within the panel.

CSS class hooks

SelectorElement
.propeller-menuRoot wrapper <div>
.propeller-menu-dropdownDesktop <nav> for dropdown-vertical style
.propeller-menu-jumbotronDesktop <nav> for jumbotron style
.propeller-menu-mobileMobile accordion <nav>

SDK Services

Why graphqlClient.execute() instead of CategoryService

The Propeller SDK's CategoryService.getCategory() returns a flat category object -- it does not include nested subcategories. To fetch a full category tree (multiple levels deep), the Menu component builds a recursive GraphQL query and executes it directly via graphqlClient.execute().

Internal query construction

The component constructs its query using a recursive buildCategoriesQuery(depth) function:

const buildCategoriesQuery = (currentDepth: number): string => {
if (currentDepth === 0) return '';
return `
categories {
categoryId
name(language: $language) { value language }
slug(language: $language) { value }
${buildCategoriesQuery(currentDepth - 1)}
}
`;
};

This produces a nested query where the categories field is repeated at each level. For depth=3, the generated query looks like:

query Menu($categoryId: Float, $language: String) {
category(categoryId: $categoryId) {
categoryId
name(language: $language) { value language }
slug(language: $language) { value }
categories {
categoryId
name(language: $language) { value language }
slug(language: $language) { value }
categories {
categoryId
name(language: $language) { value language }
slug(language: $language) { value }
categories {
categoryId
name(language: $language) { value language }
slug(language: $language) { value }
}
}
}
}
}

Execution

const response = await graphqlClient.execute({
query: gql,
variables: { categoryId: 17, language: 'NL' },
});

const rootCategory = response?.data?.category;
// rootCategory.categories -> L1 categories
// rootCategory.categories[0].categories -> L2 categories
// etc.

When a user prop is provided, the component also passes contactId (for Contact users) or customerId (for Customer users) in the query variables so the API can apply user-specific category visibility rules.