Skip to content

Commit

Permalink
Create a PrimaryNavigation component
Browse files Browse the repository at this point in the history
This component wraps Radix UI's NavigationMenu and provide's a
replacement for the current header navigation in Squareone.

With NavigationMenu we'll be able to give individual menu items
submenus, and not necessarily have to treat the user menu as a special
case.

This work styles the basic PrimaryNavigation component and demonstrates
it with a storybook story.

More work is need to make the submenu appear directly below its trigger.
  • Loading branch information
jonathansick committed Feb 12, 2025
1 parent 122d6bc commit 77274e7
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/warm-plums-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lsst-sqre/squared': minor
---

Add a new PrimaryNavigation component. This component uses the Radix [NavigationMenu](https://www.radix-ui.com/primitives/docs/components/navigation-menu) primitive and is intended to be a comprehensive solution for the primary navigation in the header of Squareone. The earlier `GafaelfawrUserMenu` component in Squared also uses `NavigationMenu`, but as a single item. With `PrimaryNavigation`, the functionality of `GafaelfawrUserMenu` can be composed into an instance of `PrimaryNavigation`. Like `GafaelfawrMenu`, `PrimaryNavigation` is set up so that menus only appear after clicking on a trigger, rather than on hover. As well, `PrimaryNavigation` ensures the menu is proximate to the trigger (an improvement on the default `NavigationMenu` functionality that centers the menu below the whole navigation element.
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, screen } from '@storybook/testing-library';
import { expect } from '@storybook/jest';

import { ChevronDown } from 'react-feather';

import { PrimaryNavigation } from './PrimaryNavigation';

const meta: Meta<typeof PrimaryNavigation> = {
title: 'Components/PrimaryNavigation',
component: PrimaryNavigation,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#1f2121' }],
},
},
};

export default meta;
type Story = StoryObj<typeof PrimaryNavigation>;

export const Default: Story = {
args: {},
render: (args) => (
<PrimaryNavigation {...args}>
<PrimaryNavigation.Item>
<PrimaryNavigation.TriggerLink href="#">
Portal
</PrimaryNavigation.TriggerLink>
</PrimaryNavigation.Item>

<PrimaryNavigation.Item>
<PrimaryNavigation.TriggerLink href="/nb">
Notebooks
</PrimaryNavigation.TriggerLink>
</PrimaryNavigation.Item>

<PrimaryNavigation.Item>
<PrimaryNavigation.TriggerLink href="/docs">
Documentation
</PrimaryNavigation.TriggerLink>
</PrimaryNavigation.Item>

<PrimaryNavigation.Item>
<PrimaryNavigation.Trigger>
Account <ChevronDown />
</PrimaryNavigation.Trigger>
<PrimaryNavigation.Content>
<PrimaryNavigation.ContentItem>
<PrimaryNavigation.Link href="#">Settings</PrimaryNavigation.Link>
</PrimaryNavigation.ContentItem>
<PrimaryNavigation.ContentItem>
<PrimaryNavigation.Link href="#">Logout</PrimaryNavigation.Link>
</PrimaryNavigation.ContentItem>
</PrimaryNavigation.Content>
</PrimaryNavigation.Item>
</PrimaryNavigation>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import React from 'react';
import styled from 'styled-components';

import * as RadixNavigationMenu from '@radix-ui/react-navigation-menu';

export interface PrimaryNavigationProps {
children: React.ReactNode;
}

/**
* Primary navigation component that provides a layout for navigation items and a viewport.
*
* @component
* @param {PrimaryNavigationProps} props - The component props
* @param {ReactNode} props.children - The navigation items to be rendered within the ItemList
* @returns {JSX.Element} A navigation component with items and a viewport container
*/
export const PrimaryNavigation = ({ children }: PrimaryNavigationProps) => {
return (
<Root>
<ItemList>{children}</ItemList>
<ViewportContainer>
<ContentViewport
onPointerEnter={(event) => event.preventDefault()}
onPointerLeave={(event) => event.preventDefault()}
/>
</ViewportContainer>
</Root>
);
};

/**
* The root component for the primary navigation.
*
* This is a styled version of the `RadixNavigationMenu.Root` component and
* isn't directly used by consumers of the component.
*/
const Root = styled(RadixNavigationMenu.Root)`
position: relative;
`;

/**
* The list of items in the primary navigation.
*
* This is a styled version of the `RadixNavigationMenu.List` component and
* isn't directly used by consumers of the component.
*/
const ItemList = styled(RadixNavigationMenu.List)`
list-style: none;
margin-bottom: 0;
padding: 0;
display: flex;
justify-self: end;
width: 100%;
font-size: 1.2rem;
`;

/**
* An item in the primary navigation that can either link to a page or display
* a dropdown menu.
*/
const Item = styled(RadixNavigationMenu.Item)`
margin: 0 1em;
`;

/**
* A trigger in the primary navigation that links to a page rather than
* displaying a dropdown menu.
*
* Use the `href` prop to specify the URL of the page.
*/
const TriggerLink = styled(RadixNavigationMenu.Link)`
color: var(--rsd-component-header-nav-text-color);
border: none;
border-radius: 0.5rem;
padding: 2px 4px;
display: inline-block; // For consistency with MenuTrigger button
/* padding: 0; */
&:hover {
color: var(--rsd-component-header-nav-text-hover-color);
}
&:focus {
outline: 1px solid var(--rsd-color-primary-500);
}
`;

/**
* The trigger for a `PrimaryNavigation.Item` that is displays a `Content`
* dropdown when activated.
*/
const Trigger = styled(RadixNavigationMenu.Trigger)`
color: var(--rsd-component-header-nav-text-color);
padding: 2px 4px;
/* padding: 0; */
// Reset button styles
background-color: transparent;
border: none;
border-radius: 0.5rem;
&:focus {
outline: 1px solid var(--rsd-color-primary-500);
}
&:hover {
color: var(--rsd-component-header-nav-text-hover-color);
}
svg {
display: inline-block;
width: 1rem;
height: 1rem;
vertical-align: middle;
}
&[data-state='open'] {
svg {
transform: rotate(180deg);
}
}
`;

/**
* The content of a `PrimaryNavigation.Item` that is displayed as a dropdown
* when the item is activated.
*/
const Content = styled(RadixNavigationMenu.Content)`
/* This unit for the padding is also the basis for the spacing and
* sizing of the menu items.
*/
--gafaelfawr-user-menu-padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
font-size: 1rem;
background-color: var(--rsd-component-header-nav-menulist-background-color);
min-width: 12rem;
border-radius: 0.5rem;
padding: var(--gafaelfawr-user-menu-padding);
padding-right: 0; // to avoid double padding on the right side with MenuLink
box-shadow: 0px 10px 38px -10px rgba(22, 23, 24, 0.35),
0px 10px 20px -15px rgba(22, 23, 24, 0.2);
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
`;

/**
* A content item within a `PrimaryNavigation.Content`.
*/
const ContentItem = styled(RadixNavigationMenu.Item)`
display: flex;
`;

/**
* A link to a page within the content of a `PrimaryNavigation.ContentItem`.
*
* Use the `href` prop to specify the URL of the page.
*/
export const Link = styled(RadixNavigationMenu.Link)`
/* The styling on the menu triggers is overriding this colour. Need to re-address. */
border-radius: 0.5rem;
padding: calc(var(--gafaelfawr-user-menu-padding) / 2)
var(--gafaelfawr-user-menu-padding);
margin: calc(var(--gafaelfawr-user-menu-padding) / -2);
margin-bottom: calc(var(--gafaelfawr-user-menu-padding) / 2);
width: 100%;
color: var(--rsd-component-header-nav-menulist-text-color);
&:last-of-type {
margin-bottom: 0;
}
outline: 1px solid transparent;
&:focus {
outline: 1px solid
var(--rsd-component-header-nav-menulist-selected-background-color);
}
&:hover {
background-color: var(
--rsd-component-header-nav-menulist-selected-background-color
);
color: white !important;
}
`;

const ViewportContainer = styled.div`
position: absolute;
display: flex;
justify-content: center;
width: 100%;
top: 100%;
left: 0;
perspective: 2000px;
`;

const ContentViewport = styled(RadixNavigationMenu.Viewport)`
position: relative;
transform-origin: top center;
margin-top: 10px;
width: 100%;
background-color: white;
border-radius: 6px;
overflow: hidden;
height: var(--radix-navigation-menu-viewport-height);
width: var(--radix-navigation-menu-viewport-width);
`;

// Associate child components with the parent for easier imports.
PrimaryNavigation.Item = Item;
PrimaryNavigation.Trigger = Trigger;
PrimaryNavigation.TriggerLink = TriggerLink;
PrimaryNavigation.Content = Content;
PrimaryNavigation.ContentItem = ContentItem;
PrimaryNavigation.Link = Link;

export default PrimaryNavigation;
2 changes: 2 additions & 0 deletions packages/squared/src/components/PrimaryNavigation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './PrimaryNavigation';
export { default } from './PrimaryNavigation';
4 changes: 4 additions & 0 deletions packages/squared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export {
type GafaelfawrUserMenuProps,
} from './components/GafaelfawrUserMenu';
export { default as IconPill, type IconPillProps } from './components/IconPill';
export {
default as PrimaryNavigation,
type PrimaryNavigationProps,
} from './components/PrimaryNavigation';

/* Hooks */
export { default as useCurrentUrl } from './hooks/useCurrentUrl';
Expand Down

0 comments on commit 77274e7

Please sign in to comment.