diff --git a/gallery/src/components/NxNavPillMenu/NxNavPillMenuDisabledExample.tsx b/gallery/src/components/NxNavPillMenu/NxNavPillMenuDisabledExample.tsx
new file mode 100644
index 0000000000..1808a38994
--- /dev/null
+++ b/gallery/src/components/NxNavPillMenu/NxNavPillMenuDisabledExample.tsx
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React from 'react';
+import { NxNavPillMenu, NavPillMenuItem } from '@sonatype/react-shared-components';
+
+const items: NavPillMenuItem[] = [
+ { id: 'dashboard', label: 'Dashboard', scrollTarget: 'dashboard-section' },
+ { id: 'analytics', label: 'Analytics', scrollTarget: 'analytics-section' },
+ { id: 'reports', label: 'Reports', scrollTarget: 'reports-section', disabled: true },
+ { id: 'settings', label: 'Settings', scrollTarget: 'settings-section' },
+ { id: 'help', label: 'Help Center', href: 'https://help.sonatype.com', disabled: false },
+ { id: 'admin', label: 'Admin Panel', scrollTarget: 'admin-section', disabled: true }
+];
+
+export default function NxNavPillMenuDisabledExample() {
+ const handleItemClick = (item: NavPillMenuItem) => {
+ if (item.disabled) {
+ console.log('Disabled item clicked:', item.label);
+ return;
+ }
+
+ if (item.href) {
+ console.log('External link clicked:', item.label, item.href);
+ } else {
+ console.log('Navigation pill clicked:', item.label);
+ }
+ };
+
+ return (
+
+
+
+
+ {/* Scrollable content container */}
+
+
+
Dashboard
+
Main dashboard view with key metrics and overview information.
+
This example demonstrates disabled states within a scrollable container.
+
+
+
+
Analytics
+
Detailed analytics and data visualization section.
+
The navigation bar stays sticky at the top of this container.
+
+
+
+
Reports (Disabled)
+
Reports section is currently disabled. The corresponding pill cannot be clicked.
+
Disabled pills show proper visual feedback and prevent interaction.
+
+
+
+
Settings
+
Application settings and configuration options.
+
The contained scroll area works seamlessly with all pill states.
+
+
+
+
External Link Example
+
The "Help Center" pill links to an external URL (https://help.sonatype.com).
+
External links work normally even within the contained scroll area.
+
+
+
+
Admin Panel (Disabled)
+
Admin panel access is restricted. This pill is disabled for regular users.
+
The scroll container ensures proper navigation behavior for all states.
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/gallery/src/components/NxNavPillMenu/NxNavPillMenuExample.tsx b/gallery/src/components/NxNavPillMenu/NxNavPillMenuExample.tsx
new file mode 100644
index 0000000000..fbcce467c1
--- /dev/null
+++ b/gallery/src/components/NxNavPillMenu/NxNavPillMenuExample.tsx
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React, { useState, useRef, useEffect } from 'react';
+import { NxNavPillMenu, NavPillMenuItem, NxFontAwesomeIcon } from '@sonatype/react-shared-components';
+import { faArrowUp } from '@fortawesome/free-solid-svg-icons';
+
+const items: NavPillMenuItem[] = [
+ { id: 'overview', label: 'Overview', scrollTarget: 'overview-section' },
+ { id: 'features', label: 'Features', scrollTarget: 'features-section' },
+ { id: 'getting-started', label: 'Getting Started', scrollTarget: 'getting-started-section' },
+ { id: 'api', label: 'API Reference', scrollTarget: 'api-section' },
+ { id: 'examples', label: 'Examples', scrollTarget: 'examples-section' }
+];
+
+export default function NxNavPillMenuExample() {
+ const [showBackToTop, setShowBackToTop] = useState(false);
+ const scrollContainerRef = useRef(null);
+
+ const handleItemClick = (item: NavPillMenuItem) => {
+ console.log('Clicked nav pill:', item.label);
+ };
+
+ // Back to top functionality
+ const scrollToTop = () => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ });
+ }
+ };
+
+ // Monitor scroll position to show/hide back-to-top button
+ useEffect(() => {
+ const handleScroll = () => {
+ if (scrollContainerRef.current) {
+ const scrollTop = scrollContainerRef.current.scrollTop;
+ setShowBackToTop(scrollTop > 200); // Show after scrolling 200px
+ }
+ };
+
+ const scrollContainer = scrollContainerRef.current;
+ if (scrollContainer) {
+ scrollContainer.addEventListener('scroll', handleScroll);
+ return () => scrollContainer.removeEventListener('scroll', handleScroll);
+ }
+
+ return undefined; // Explicit return for when scrollContainer is null
+ }, []);
+
+ return (
+
+
+
+
+ {/* Scrollable content container */}
+
+
+
Overview Section
+
This is the overview section. Clicking the "Overview" pill will scroll to this section within this container.
+
The navigation bar stays at the top of this container, not the entire page.
+
+
+
+
Features Section
+
This is the features section. Notice how the navigation scrolls within this bounded area.
+
This prevents conflicts with the main page scroll.
+
+
+
+
Getting Started Section
+
This is the getting started section. Click the pill to scroll here.
+
The scroll behavior is contained within this example area.
+
+
+
+
API Reference Section
+
This is the API reference section with detailed documentation.
+
Each section has enough content to demonstrate the scroll behavior.
+
+
+
+
Examples Section
+
This is the examples section showing various use cases.
+
The contained scroll area ensures the sticky navigation works properly.
+
+
+
+ {/* Back to top button - demo pattern for nav pill menu */}
+ {showBackToTop && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/gallery/src/components/NxNavPillMenu/NxNavPillMenuPage.tsx b/gallery/src/components/NxNavPillMenu/NxNavPillMenuPage.tsx
new file mode 100644
index 0000000000..e304ac434a
--- /dev/null
+++ b/gallery/src/components/NxNavPillMenu/NxNavPillMenuPage.tsx
@@ -0,0 +1,215 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React from 'react';
+import {
+ NxCode,
+ NxH3,
+ NxInfoAlert,
+ NxP,
+ NxTable,
+ NxTile,
+ NxWarningAlert
+} from '@sonatype/react-shared-components';
+
+import { GalleryDescriptionTile, GalleryExampleTile } from '../../gallery-components/GalleryTiles';
+
+import NxNavPillMenuExample from './NxNavPillMenuExample';
+import NxStatefulNavPillMenuExample from './NxStatefulNavPillMenuExample';
+import NxNavPillMenuDisabledExample from './NxNavPillMenuDisabledExample';
+import NxNavPillMenuResponsiveExample from './NxNavPillMenuResponsiveExample';
+
+const exampleCode = require('./NxNavPillMenuExample?raw'),
+ statefulExampleCode = require('./NxStatefulNavPillMenuExample?raw'),
+ disabledExampleCode = require('./NxNavPillMenuDisabledExample?raw'),
+ responsiveExampleCode = require('./NxNavPillMenuResponsiveExample?raw');
+
+const NxNavPillMenuPage = () =>
+ <>
+
+
+ NxNavPillMenu provides a pill-shaped navigation menu component for creating intuitive,
+ interactive navigation experiences. The component supports smooth scrolling to page sections, external links,
+ and various interactive states including hover, active, and disabled states.
+
+
+ The component is designed to be responsive, wrapping pills on larger screens and providing horizontal
+ scrolling on mobile devices. It integrates seamlessly with the Sonatype design system and supports
+ both controlled and stateful variants.
+
+
+
+ Props
+
+
+
+
+ Prop
+ Type
+ Required
+ Description
+
+
+
+
+ items
+ NavPillMenuItem[]
+ Yes
+
+ Array of navigation items. Each item should have an id, label,
+ and either a scrollTarget or href. Items can also be disabled.
+
+
+
+ activeItem
+ string | null
+ No
+ The ID of the currently active item. Used to highlight the active pill.
+
+
+ onItemClick
+ Function
+ No
+
+ Callback function called when an item is clicked. Receives the clicked item and the click event.
+
+
+
+ scrollBehavior
+ 'smooth' | 'auto'
+ No
+ Scroll behavior when navigating to sections. Defaults to 'smooth'.
+
+
+ scrollOffset
+ number
+ No
+
+ Offset in pixels from the top when scrolling to sections. Useful for fixed headers. Defaults to 0.
+
+
+
+ className
+ string
+ No
+ Additional CSS classes to apply to the navigation container.
+
+
+
+
+
+
+
+ NavPillMenuItem Interface
+
+
+
+
+ Property
+ Type
+ Required
+ Description
+
+
+
+
+ id
+ string
+ Yes
+ Unique identifier for the navigation item.
+
+
+ label
+ string
+ Yes
+ Display text for the navigation pill.
+
+
+ scrollTarget
+ string
+ No
+
+ ID or CSS selector of the element to scroll to when the pill is clicked.
+ Mutually exclusive with href.
+
+
+
+ href
+ string
+ No
+
+ URL to navigate to when the pill is clicked. Used for external links.
+ Mutually exclusive with scrollTarget.
+
+
+
+ disabled
+ boolean
+ No
+ Whether the navigation pill is disabled. Defaults to false.
+
+
+
+
+
+
+
+ Stateful Variant
+
+
+ NxStatefulNavPillMenu provides a convenience wrapper that automatically manages
+ the active state. It accepts an initialActiveItem prop and an
+ onItemChange callback that's triggered when the active item changes.
+
+
+
+
+ The component automatically handles scroll navigation when scrollTarget is provided.
+ For external links, use the href property instead.
+
+
+
+ Ensure that target elements for scroll navigation exist in the DOM and have the specified IDs
+ or match the CSS selectors provided in scrollTarget.
+
+
+
+
+ A basic example showing controlled navigation pills with smooth scrolling to page sections.
+ The "Features" pill is set as active in this example.
+
+
+
+ This example demonstrates the stateful variant that automatically manages the active state.
+ Click different pills to see the active state change automatically.
+
+
+
+ This example shows navigation pills with disabled items and external links.
+ Disabled pills are visually distinct and cannot be clicked, while external links
+ navigate to different URLs.
+
+
+
+ This example demonstrates responsive behavior with many navigation items.
+ Pills wrap on larger screens and scroll horizontally on mobile devices.
+ Try resizing your browser window to see the responsive behavior.
+
+ >;
+
+export default NxNavPillMenuPage;
\ No newline at end of file
diff --git a/gallery/src/components/NxNavPillMenu/NxNavPillMenuResponsiveExample.tsx b/gallery/src/components/NxNavPillMenu/NxNavPillMenuResponsiveExample.tsx
new file mode 100644
index 0000000000..3fa7673a59
--- /dev/null
+++ b/gallery/src/components/NxNavPillMenu/NxNavPillMenuResponsiveExample.tsx
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React from 'react';
+import { NxNavPillMenu, NavPillMenuItem } from '@sonatype/react-shared-components';
+
+const manyItems: NavPillMenuItem[] = [
+ { id: 'introduction', label: 'Introduction', scrollTarget: 'intro-section' },
+ { id: 'installation', label: 'Installation', scrollTarget: 'install-section' },
+ { id: 'quick-start', label: 'Quick Start Guide', scrollTarget: 'quickstart-section' },
+ { id: 'components', label: 'Components', scrollTarget: 'components-section' },
+ { id: 'theming', label: 'Theming & Customization', scrollTarget: 'theming-section' },
+ { id: 'accessibility', label: 'Accessibility', scrollTarget: 'a11y-section' },
+ { id: 'testing', label: 'Testing', scrollTarget: 'testing-section' },
+ { id: 'performance', label: 'Performance', scrollTarget: 'performance-section' },
+ { id: 'deployment', label: 'Deployment', scrollTarget: 'deployment-section' },
+ { id: 'troubleshooting', label: 'Troubleshooting', scrollTarget: 'troubleshooting-section' },
+ { id: 'faq', label: 'FAQ', scrollTarget: 'faq-section' },
+ { id: 'changelog', label: 'Changelog', href: '/changelog' },
+ { id: 'github', label: 'GitHub Repository', href: 'https://github.com/sonatype/react-shared-components' }
+];
+
+export default function NxNavPillMenuResponsiveExample() {
+ const handleItemClick = (item: NavPillMenuItem) => {
+ console.log('Clicked:', item.label);
+ };
+
+ return (
+
+
+
Responsive Navigation Pills with Contained Scroll
+
+ This example demonstrates the responsive behavior with many navigation items within a scrollable container.
+ The pills now scroll horizontally when there's overflow instead of wrapping to a second row.
+
+
+ The navigation is contained within a fixed-height scrollable area to prevent conflicts with page scrolling.
+
+
+
+
+
+
+
+ {/* Scrollable content container */}
+
+
+
Introduction
+
Welcome to the component library documentation. This responsive navigation makes it easy to jump between sections.
+
+
+
+
Installation
+
Learn how to install and set up the component library in your project.
+
+
+
+
Quick Start Guide
+
Get up and running quickly with our step-by-step guide.
+
+
+
+
Components
+
Comprehensive documentation of all available components and their APIs.
+
This section demonstrates the horizontal scrolling behavior when there are many navigation items.
+
+
+
+
Theming & Customization
+
Learn how to customize the appearance of components to match your brand.
+
+
+
+
Accessibility
+
Guidelines and best practices for making your application accessible to all users.
+
+
+
+
Testing
+
Testing strategies and utilities for components in your application.
+
+
+
+
Performance
+
Optimization techniques and performance considerations for better user experience.
+
+
+
+
Deployment
+
Best practices for deploying applications that use this component library.
+
+
+
+
Troubleshooting
+
Common issues and their solutions to help you resolve problems quickly.
+
+
+
+
FAQ
+
Frequently asked questions and their answers.
+
This approach provides the best user experience for navigation components.
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/gallery/src/components/NxNavPillMenu/NxStatefulNavPillMenuExample.tsx b/gallery/src/components/NxNavPillMenu/NxStatefulNavPillMenuExample.tsx
new file mode 100644
index 0000000000..0716aa8540
--- /dev/null
+++ b/gallery/src/components/NxNavPillMenu/NxStatefulNavPillMenuExample.tsx
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React, { useState, useRef, useEffect } from 'react';
+import { NxStatefulNavPillMenu, NavPillMenuItem, NxFontAwesomeIcon } from '@sonatype/react-shared-components';
+import { faArrowUp } from '@fortawesome/free-solid-svg-icons';
+
+const items: NavPillMenuItem[] = [
+ { id: 'home', label: 'Home', scrollTarget: 'home-section' },
+ { id: 'about', label: 'About Us', scrollTarget: 'about-section' },
+ { id: 'services', label: 'Services', scrollTarget: 'services-section' },
+ { id: 'portfolio', label: 'Portfolio', scrollTarget: 'portfolio-section' },
+ { id: 'contact', label: 'Contact', scrollTarget: 'contact-section' }
+];
+
+export default function NxStatefulNavPillMenuExample() {
+ const [showBackToTop, setShowBackToTop] = useState(false);
+ const scrollContainerRef = useRef(null);
+
+ const handleItemChange = (item: NavPillMenuItem) => {
+ console.log('Navigation changed to:', item.label);
+ };
+
+ // Back to top functionality
+ const scrollToTop = () => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ });
+ }
+ };
+
+ // Monitor scroll position to show/hide back-to-top button
+ useEffect(() => {
+ const handleScroll = () => {
+ if (scrollContainerRef.current) {
+ const scrollTop = scrollContainerRef.current.scrollTop;
+ setShowBackToTop(scrollTop > 200); // Show after scrolling 200px
+ }
+ };
+
+ const scrollContainer = scrollContainerRef.current;
+ if (scrollContainer) {
+ scrollContainer.addEventListener('scroll', handleScroll);
+ return () => scrollContainer.removeEventListener('scroll', handleScroll);
+ }
+
+ return undefined; // Explicit return for when scrollContainer is null
+ }, []);
+
+ return (
+
+
+
+
+ {/* Scrollable content container */}
+
+
+
Home
+
Welcome to our homepage! This nav pill menu works within a contained scrollable area.
+
The navigation stays at the top of this container, not the entire page.
+
+
+
+
About Us
+
Learn more about our company and mission. The pills provide smooth navigation between sections.
+
The scroll behavior is isolated within this demo container.
+
+
+
+
Services
+
Discover the services we offer. Each pill scrolls smoothly to its target section.
+
This prevents conflicts with the main gallery page scroll.
+
+
+
+
Portfolio
+
View our portfolio of work and achievements. The contained scroll works perfectly.
+
This approach isolates the navigation scroll context.
+
+
+
+
Contact
+
Get in touch with us. The scrollable container ensures proper behavior.
+
Each section remains fully accessible via the sticky navigation.
+
+
+
+ {/* Back to top button - demo pattern for nav pill menu */}
+ {showBackToTop && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/gallery/src/pageConfig.ts b/gallery/src/pageConfig.ts
index f1088ea659..b5754e2d06 100644
--- a/gallery/src/pageConfig.ts
+++ b/gallery/src/pageConfig.ts
@@ -169,6 +169,7 @@ import NxMeterPage from './components/NxMeter/NxMeterPage';
import NivoPage from './styles/Nivo/NivoPage';
import NxBreadcrumbPage from './components/NxBreadcrumb/NxBreadcrumbPage';
import NxStatefulBreadcrumbPage from './components/NxStatefulBreadcrumb/NxStatefulBreadcrumbPage';
+import NxNavPillMenuPage from './components/NxNavPillMenu/NxNavPillMenuPage';
import DarkModeClassPage from './styles/DarkMode/DarkModeClassPage';
import DarkModeMixinPage from './styles/DarkMode/DarkModeMixinPage';
import NxSmallTagPage from './components/NxSmallTag/NxSmallTagPage';
@@ -210,6 +211,7 @@ const pageConfig: PageConfig = {
'Back Button': { content: NxBackButtonPage, type: 'react' },
'Breadcrumb': { content: NxBreadcrumbPage, type: 'react' },
'Stateful Breadcrumb': { content: NxStatefulBreadcrumbPage, type: 'react' },
+ 'Nav Pill Menu': { content: NxNavPillMenuPage, type: 'react' },
'Button': { content: NxButtonPage, type: 'react' },
'Segmented Button': { content: NxSegmentedButtonPage, type: 'react' },
'Stateful Segmented Button': { content: NxStatefulSegmentedButtonPage, type: 'react' },
diff --git a/lib/src/components/NxNavPillMenu/NxNavPillMenu.scss b/lib/src/components/NxNavPillMenu/NxNavPillMenu.scss
new file mode 100644
index 0000000000..055570b030
--- /dev/null
+++ b/lib/src/components/NxNavPillMenu/NxNavPillMenu.scss
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+@use '../../scss-shared/nx-container-helpers';
+@use '../../scss-shared/nx-text-helpers';
+
+.nx-nav-pill-menu {
+ // Container with indigo-90 background and 12px rounded corners
+ background-color: var(--nx-swatch-indigo-90);
+ border-radius: 12px;
+ padding: 4px; // Reduced padding to achieve 41px total height
+ margin: 0 0 var(--nx-spacing-4x) 0; // Remove top margin
+ height: 41px; // Fixed height as requested
+ // Sticky positioning to keep nav bar at top
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ box-sizing: border-box;
+ // Dynamic width that hugs content but respects container bounds
+ width: fit-content;
+ max-width: 100%;
+ // Flex layout for arrows and list
+ display: flex;
+ align-items: center;
+
+ @include nx-container-helpers.container-horizontal;
+}
+
+.nx-nav-pill-menu__list {
+ display: flex;
+ flex-wrap: nowrap; // Changed from wrap to nowrap for horizontal scrolling
+ gap: 8px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ align-items: center;
+ flex: 1; // Take up available space
+ // Enable horizontal scrolling when pills overflow
+ overflow-x: auto;
+ // Hide scrollbars completely
+ scrollbar-width: none; // Firefox
+ -ms-overflow-style: none; // IE/Edge
+
+ &::-webkit-scrollbar {
+ display: none; // Chrome, Safari, Opera
+ }
+}
+
+.nx-nav-pill-menu__list-item {
+ flex-shrink: 0;
+}
+
+.nx-nav-pill-menu__item {
+ @include nx-text-helpers.semi-bold;
+ @include nx-text-helpers.truncate-ellipsis;
+
+ align-items: center;
+ background-color: transparent;
+ border: none;
+ border-radius: 12px; // Pill shape
+ box-sizing: border-box;
+ color: var(--nx-swatch-indigo-40);
+ cursor: pointer;
+ display: inline-flex;
+ font-family: var(--nx-font-family); // Open Sans
+ font-size: 14px; // Specific 14px as requested
+ font-weight: 600; // Semibold
+ height: 33px; // Fixed height as requested
+ line-height: 1;
+ margin: 0; // No margin needed, using gap spacing instead
+ max-width: 200px;
+ min-width: 60px;
+ padding: 0 8px; // 8px horizontal padding as requested
+ position: relative;
+ text-align: center;
+ text-decoration: none;
+ transition: background-color 0.2s ease-in-out;
+ user-select: none;
+ white-space: nowrap;
+
+ &:hover:not(&--disabled) {
+ background-color: var(--nx-swatch-indigo-97);
+ }
+
+ &:active:not(&--disabled) {
+ background-color: var(--nx-swatch-white);
+ border: none;
+ }
+
+ &:focus {
+ outline: none;
+ background-color: var(--nx-swatch-indigo-97);
+ }
+
+ &:focus-visible {
+ outline: none;
+ background-color: var(--nx-swatch-indigo-97);
+ }
+
+ &--disabled {
+ color: var(--nx-color-text-disabled);
+ cursor: not-allowed;
+ opacity: 0.6;
+
+ &:hover {
+ background-color: transparent;
+ }
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ // Remove default link styles
+ &:visited {
+ color: inherit;
+ }
+}
+
+// Scroll arrow buttons
+.nx-nav-pill-menu__scroll-arrow {
+ background: transparent;
+ border: none;
+ color: var(--nx-swatch-indigo-40);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 33px; // Match pill height
+ width: 32px;
+ border-radius: 12px;
+ flex-shrink: 0;
+ transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
+
+ &:hover {
+ background-color: var(--nx-swatch-indigo-97);
+ color: var(--nx-swatch-indigo-30);
+ }
+
+ &:active {
+ background-color: var(--nx-swatch-white);
+ color: var(--nx-swatch-indigo-40);
+ }
+
+ &:focus {
+ outline: none;
+ background-color: var(--nx-swatch-indigo-97);
+ }
+
+ // Position arrows outside the rounded container
+ &--left {
+ margin-right: 4px;
+ }
+
+ &--right {
+ margin-left: 4px;
+ }
+
+ .nx-icon {
+ font-size: 14px;
+ }
+}
+
+
+// Responsive adjustments
+@media (max-width: 480px) {
+ .nx-nav-pill-menu {
+ padding: 3px; // Slightly reduced for mobile
+ height: 39px; // Slightly smaller on mobile
+ // Maintain dynamic width behavior on mobile
+ width: fit-content;
+ max-width: 100%;
+ }
+
+ .nx-nav-pill-menu__item {
+ font-size: 12px;
+ height: 31px; // Proportionally smaller on mobile
+ padding: 0 6px; // Proportionally smaller padding on mobile
+ min-width: 50px;
+ max-width: 150px;
+ border-radius: 10px; // Proportionally smaller radius on mobile
+ }
+
+ .nx-nav-pill-menu__scroll-arrow {
+ height: 31px; // Match smaller pill height on mobile
+ width: 28px; // Proportionally smaller
+ border-radius: 10px; // Match pill radius
+
+ .nx-icon {
+ font-size: 12px; // Smaller icon on mobile
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/src/components/NxNavPillMenu/NxNavPillMenu.tsx b/lib/src/components/NxNavPillMenu/NxNavPillMenu.tsx
new file mode 100644
index 0000000000..1463ceeccf
--- /dev/null
+++ b/lib/src/components/NxNavPillMenu/NxNavPillMenu.tsx
@@ -0,0 +1,234 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React, { forwardRef, useRef, useState, useEffect } from 'react';
+import classnames from 'classnames';
+import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
+
+import { NxNavPillMenuProps, NavPillMenuItem, nxNavPillMenuPropTypes } from './types';
+import NxOverflowTooltip from '../NxTooltip/NxOverflowTooltip';
+import NxFontAwesomeIcon from '../NxFontAwesomeIcon/NxFontAwesomeIcon';
+
+import './NxNavPillMenu.scss';
+
+export { NxNavPillMenuProps, NavPillMenuItem };
+
+const NxNavPillMenu = forwardRef(function NxNavPillMenuElement(props, ref) {
+ const {
+ items,
+ onItemClick,
+ scrollBehavior = 'smooth',
+ scrollOffset = 0,
+ className,
+ ...otherProps
+ } = props;
+
+ const pillListRef = useRef(null);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(false);
+ const [showScrollArrows, setShowScrollArrows] = useState(false);
+
+ const handleItemClick = (item: NavPillMenuItem, event: React.MouseEvent) => {
+ // Handle scroll navigation
+ if (item.scrollTarget && !item.disabled) {
+ event.preventDefault();
+
+ const targetElement = document.getElementById(item.scrollTarget) ||
+ document.querySelector(item.scrollTarget);
+
+ console.log('=== DEBUG NAV PILL CLICK ===');
+ console.log('Target element:', targetElement);
+ console.log('Target ID:', item.scrollTarget);
+
+ if (targetElement) {
+ // Try to find the scroll container that contains the target element
+ let scrollContainer: Element | null = null;
+ let currentElement: Element | null = targetElement.parentElement;
+
+ // Walk up from the target element to find a scrollable container
+ while (currentElement && currentElement !== document.body && currentElement !== document.documentElement) {
+ const computedStyle = window.getComputedStyle(currentElement);
+ console.log('Checking element:', currentElement, {
+ overflowY: computedStyle.overflowY,
+ overflow: computedStyle.overflow,
+ hasDataAttr: currentElement.hasAttribute('data-nav-scroll-area')
+ });
+
+ if (currentElement.hasAttribute('data-nav-scroll-area') ||
+ computedStyle.overflowY === 'auto' ||
+ computedStyle.overflowY === 'scroll' ||
+ computedStyle.overflow === 'auto' ||
+ computedStyle.overflow === 'scroll') {
+ scrollContainer = currentElement;
+ console.log('FOUND SCROLL CONTAINER:', scrollContainer);
+ break;
+ }
+
+ currentElement = currentElement.parentElement;
+ }
+
+ // If we found a scrollable container, scroll within it
+ if (scrollContainer && scrollContainer !== document.body && scrollContainer !== document.documentElement) {
+ console.log('SCROLLING IN CONTAINER');
+ console.log('Container scroll top before:', scrollContainer.scrollTop);
+
+ const containerRect = scrollContainer.getBoundingClientRect();
+ const targetRect = targetElement.getBoundingClientRect();
+ const relativeTop = targetRect.top - containerRect.top + scrollContainer.scrollTop;
+ const offsetPosition = Math.max(0, relativeTop - scrollOffset);
+
+ console.log('Scroll calculation:', {
+ containerTop: containerRect.top,
+ targetTop: targetRect.top,
+ currentScrollTop: scrollContainer.scrollTop,
+ relativeTop,
+ offsetPosition
+ });
+
+ scrollContainer.scrollTo({
+ top: offsetPosition,
+ behavior: scrollBehavior
+ });
+
+ // Verify scroll happened
+ setTimeout(() => {
+ console.log('Container scroll top after:', scrollContainer!.scrollTop);
+ }, 100);
+ } else {
+ console.log('NO SCROLL CONTAINER FOUND - USING WINDOW SCROLL');
+ const elementTop = targetElement.getBoundingClientRect().top + window.pageYOffset;
+ const offsetPosition = elementTop - scrollOffset;
+ window.scrollTo({
+ top: offsetPosition,
+ behavior: scrollBehavior
+ });
+ }
+ }
+ }
+
+ // Call user-provided callback
+ onItemClick?.(item, event);
+ };
+
+ const renderPillItem = (item: NavPillMenuItem) => {
+ const isDisabled = item.disabled;
+
+ const pillClassNames = classnames('nx-nav-pill-menu__item', {
+ 'nx-nav-pill-menu__item--disabled': isDisabled
+ });
+
+ const pillContent = (
+ handleItemClick(item, event)}
+ aria-disabled={isDisabled}
+ tabIndex={isDisabled ? -1 : undefined}
+ >
+ {item.label}
+
+ );
+
+ return (
+
+
+ {pillContent}
+
+
+ );
+ };
+
+ // Check if scrolling is needed and update arrow visibility
+ const updateScrollArrows = () => {
+ if (pillListRef.current) {
+ const { scrollLeft, scrollWidth, clientWidth } = pillListRef.current;
+ const hasOverflow = scrollWidth > clientWidth;
+ const canScrollLeftValue = scrollLeft > 0;
+ const canScrollRightValue = scrollLeft < (scrollWidth - clientWidth - 1); // -1 for rounding
+
+ setShowScrollArrows(hasOverflow);
+ setCanScrollLeft(canScrollLeftValue);
+ setCanScrollRight(canScrollRightValue);
+ }
+ };
+
+ // Handle scroll arrow clicks
+ const scrollLeft = () => {
+ if (pillListRef.current) {
+ pillListRef.current.scrollBy({ left: -200, behavior: 'smooth' });
+ }
+ };
+
+ const scrollRight = () => {
+ if (pillListRef.current) {
+ pillListRef.current.scrollBy({ left: 200, behavior: 'smooth' });
+ }
+ };
+
+
+ // Update scroll arrows on mount and when items change
+ useEffect(() => {
+ updateScrollArrows();
+
+ const handleResize = () => updateScrollArrows();
+ window.addEventListener('resize', handleResize);
+
+ return () => window.removeEventListener('resize', handleResize);
+ }, [items]);
+
+ // Update scroll arrows on scroll
+ const handleScroll = () => {
+ updateScrollArrows();
+ };
+
+ const navClassNames = classnames('nx-nav-pill-menu', className, {
+ 'nx-nav-pill-menu--scrollable': showScrollArrows
+ });
+
+ return (
+
+ );
+});
+
+NxNavPillMenu.propTypes = nxNavPillMenuPropTypes;
+
+export default NxNavPillMenu;
\ No newline at end of file
diff --git a/lib/src/components/NxNavPillMenu/__tests__/NxNavPillMenu.test.tsx b/lib/src/components/NxNavPillMenu/__tests__/NxNavPillMenu.test.tsx
new file mode 100644
index 0000000000..8079ea9821
--- /dev/null
+++ b/lib/src/components/NxNavPillMenu/__tests__/NxNavPillMenu.test.tsx
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import NxNavPillMenu, { NavPillMenuItem } from '../NxNavPillMenu';
+
+describe('NxNavPillMenu', () => {
+ const mockItems: NavPillMenuItem[] = [
+ { id: 'item1', label: 'Item 1', scrollTarget: 'section1' },
+ { id: 'item2', label: 'Item 2', scrollTarget: 'section2' },
+ { id: 'item3', label: 'Item 3', href: '/item3' },
+ { id: 'item4', label: 'Disabled Item', scrollTarget: 'section4', disabled: true }
+ ];
+
+ const mockScrollTo = jest.fn();
+
+ beforeAll(() => {
+ Object.defineProperty(window, 'scrollTo', {
+ value: mockScrollTo,
+ writable: true
+ });
+ });
+
+ beforeEach(() => {
+ mockScrollTo.mockClear();
+
+ // Mock getBoundingClientRect
+ Element.prototype.getBoundingClientRect = jest.fn(() => ({
+ top: 100,
+ left: 0,
+ bottom: 200,
+ right: 200,
+ width: 200,
+ height: 100,
+ x: 0,
+ y: 100,
+ toJSON: jest.fn()
+ }));
+
+ // Mock pageYOffset
+ Object.defineProperty(window, 'pageYOffset', {
+ value: 0,
+ writable: true
+ });
+ });
+
+ it('renders correctly with basic props', () => {
+ render();
+
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
+ expect(screen.getByText('Item 2')).toBeInTheDocument();
+ expect(screen.getByText('Item 3')).toBeInTheDocument();
+ expect(screen.getByText('Disabled Item')).toBeInTheDocument();
+ });
+
+ it('applies active state correctly', () => {
+ render();
+
+ const activeItem = screen.getByText('Item 1');
+ expect(activeItem).toHaveClass('nx-nav-pill-menu__item--active');
+ expect(activeItem).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('applies disabled state correctly', () => {
+ render();
+
+ const disabledItem = screen.getByText('Disabled Item');
+ expect(disabledItem).toHaveClass('nx-nav-pill-menu__item--disabled');
+ expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
+ expect(disabledItem).toHaveAttribute('tabIndex', '-1');
+ });
+
+ it('handles scroll navigation on item click', () => {
+ const mockElement = document.createElement('div');
+ mockElement.id = 'section1';
+ document.body.appendChild(mockElement);
+
+ render();
+
+ const item1 = screen.getByText('Item 1');
+ fireEvent.click(item1);
+
+ expect(mockScrollTo).toHaveBeenCalledWith({
+ top: 50, // 100 (mock getBoundingClientRect top) + 0 (pageYOffset) - 50 (scrollOffset)
+ behavior: 'smooth'
+ });
+
+ document.body.removeChild(mockElement);
+ });
+
+ it('calls onItemClick callback', () => {
+ const mockOnItemClick = jest.fn();
+
+ render();
+
+ const item1 = screen.getByText('Item 1');
+ fireEvent.click(item1);
+
+ expect(mockOnItemClick).toHaveBeenCalledWith(
+ mockItems[0],
+ expect.any(Object)
+ );
+ });
+
+ it('calls onItemClick for disabled items but does not scroll', () => {
+ const mockOnItemClick = jest.fn();
+
+ render();
+
+ const disabledItem = screen.getByText('Disabled Item');
+ fireEvent.click(disabledItem);
+
+ expect(mockOnItemClick).toHaveBeenCalledWith(
+ mockItems[3],
+ expect.any(Object)
+ );
+ expect(mockScrollTo).not.toHaveBeenCalled();
+ });
+
+ it('renders with custom className', () => {
+ render();
+
+ const nav = screen.getByRole('navigation');
+ expect(nav).toHaveClass('nx-nav-pill-menu', 'custom-class');
+ });
+
+ it('supports ref forwarding', () => {
+ const ref = React.createRef();
+ render();
+
+ expect(ref.current).toBeInstanceOf(HTMLElement);
+ expect(ref.current?.tagName).toBe('NAV');
+ });
+
+ it('uses auto scroll behavior when specified', () => {
+ const mockElement = document.createElement('div');
+ mockElement.id = 'section1';
+ document.body.appendChild(mockElement);
+
+ render();
+
+ const item1 = screen.getByText('Item 1');
+ fireEvent.click(item1);
+
+ expect(mockScrollTo).toHaveBeenCalledWith({
+ top: 100,
+ behavior: 'auto'
+ });
+
+ document.body.removeChild(mockElement);
+ });
+
+ it('handles items with href attribute', () => {
+ render();
+
+ const item3 = screen.getByText('Item 3');
+ expect(item3).toHaveAttribute('href', '/item3');
+ });
+});
\ No newline at end of file
diff --git a/lib/src/components/NxNavPillMenu/stateful/NxStatefulNavPillMenu.tsx b/lib/src/components/NxNavPillMenu/stateful/NxStatefulNavPillMenu.tsx
new file mode 100644
index 0000000000..663438c466
--- /dev/null
+++ b/lib/src/components/NxNavPillMenu/stateful/NxStatefulNavPillMenu.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React, { forwardRef } from 'react';
+
+import NxNavPillMenu from '../NxNavPillMenu';
+import { NavPillMenuItem } from '../types';
+import { NxStatefulNavPillMenuProps, nxStatefulNavPillMenuPropTypes } from './types';
+
+export { NxStatefulNavPillMenuProps };
+
+const NxStatefulNavPillMenu = forwardRef(
+ function NxStatefulNavPillMenuElement(props, ref) {
+ const {
+ items,
+ onItemChange,
+ ...otherProps
+ } = props;
+
+ const handleItemClick = (item: NavPillMenuItem) => {
+ if (!item.disabled) {
+ onItemChange?.(item);
+ }
+ };
+
+ return (
+
+ );
+ }
+);
+
+NxStatefulNavPillMenu.propTypes = nxStatefulNavPillMenuPropTypes;
+
+export default NxStatefulNavPillMenu;
\ No newline at end of file
diff --git a/lib/src/components/NxNavPillMenu/stateful/__tests__/NxStatefulNavPillMenu.test.tsx b/lib/src/components/NxNavPillMenu/stateful/__tests__/NxStatefulNavPillMenu.test.tsx
new file mode 100644
index 0000000000..861d9bee02
--- /dev/null
+++ b/lib/src/components/NxNavPillMenu/stateful/__tests__/NxStatefulNavPillMenu.test.tsx
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import NxStatefulNavPillMenu from '../NxStatefulNavPillMenu';
+import { NavPillMenuItem } from '../../types';
+
+describe('NxStatefulNavPillMenu', () => {
+ const mockItems: NavPillMenuItem[] = [
+ { id: 'item1', label: 'Item 1', scrollTarget: 'section1' },
+ { id: 'item2', label: 'Item 2', scrollTarget: 'section2' },
+ { id: 'item3', label: 'Item 3', href: '/item3' },
+ { id: 'item4', label: 'Disabled Item', scrollTarget: 'section4', disabled: true }
+ ];
+
+ beforeAll(() => {
+ Object.defineProperty(window, 'scrollTo', {
+ value: jest.fn(),
+ writable: true
+ });
+ });
+
+ beforeEach(() => {
+ Element.prototype.getBoundingClientRect = jest.fn(() => ({
+ top: 100,
+ left: 0,
+ bottom: 200,
+ right: 200,
+ width: 200,
+ height: 100,
+ x: 0,
+ y: 100,
+ toJSON: jest.fn()
+ }));
+
+ Object.defineProperty(window, 'pageYOffset', {
+ value: 0,
+ writable: true
+ });
+ });
+
+ it('renders correctly with basic props', () => {
+ render();
+
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
+ expect(screen.getByText('Item 2')).toBeInTheDocument();
+ expect(screen.getByText('Item 3')).toBeInTheDocument();
+ expect(screen.getByText('Disabled Item')).toBeInTheDocument();
+ });
+
+ it('sets initial active item', () => {
+ render();
+
+ const activeItem = screen.getByText('Item 2');
+ expect(activeItem).toHaveClass('nx-nav-pill-menu__item--active');
+ expect(activeItem).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('manages active state internally', () => {
+ render();
+
+ // Initially no item should be active
+ expect(screen.getByText('Item 1')).not.toHaveClass('nx-nav-pill-menu__item--active');
+ expect(screen.getByText('Item 2')).not.toHaveClass('nx-nav-pill-menu__item--active');
+
+ // Click on Item 1
+ fireEvent.click(screen.getByText('Item 1'));
+
+ expect(screen.getByText('Item 1')).toHaveClass('nx-nav-pill-menu__item--active');
+ expect(screen.getByText('Item 2')).not.toHaveClass('nx-nav-pill-menu__item--active');
+
+ // Click on Item 2
+ fireEvent.click(screen.getByText('Item 2'));
+
+ expect(screen.getByText('Item 1')).not.toHaveClass('nx-nav-pill-menu__item--active');
+ expect(screen.getByText('Item 2')).toHaveClass('nx-nav-pill-menu__item--active');
+ });
+
+ it('calls onItemChange callback when item is clicked', () => {
+ const mockOnItemChange = jest.fn();
+
+ render();
+
+ fireEvent.click(screen.getByText('Item 1'));
+
+ expect(mockOnItemChange).toHaveBeenCalledWith(mockItems[0]);
+ });
+
+ it('does not change state or call callback for disabled items', () => {
+ const mockOnItemChange = jest.fn();
+
+ render();
+
+ // First click on enabled item
+ fireEvent.click(screen.getByText('Item 1'));
+ expect(screen.getByText('Item 1')).toHaveClass('nx-nav-pill-menu__item--active');
+ expect(mockOnItemChange).toHaveBeenCalledTimes(1);
+
+ // Then click on disabled item
+ fireEvent.click(screen.getByText('Disabled Item'));
+
+ // Active state should not change
+ expect(screen.getByText('Item 1')).toHaveClass('nx-nav-pill-menu__item--active');
+ expect(screen.getByText('Disabled Item')).not.toHaveClass('nx-nav-pill-menu__item--active');
+
+ // Callback should not be called again
+ expect(mockOnItemChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('supports ref forwarding', () => {
+ const ref = React.createRef();
+ render();
+
+ expect(ref.current).toBeInstanceOf(HTMLElement);
+ expect(ref.current?.tagName).toBe('NAV');
+ });
+
+ it('passes through other props to underlying component', () => {
+ render(
+
+ );
+
+ const nav = screen.getByRole('navigation');
+ expect(nav).toHaveClass('nx-nav-pill-menu', 'custom-class');
+ });
+});
\ No newline at end of file
diff --git a/lib/src/components/NxNavPillMenu/stateful/types.ts b/lib/src/components/NxNavPillMenu/stateful/types.ts
new file mode 100644
index 0000000000..ab893826da
--- /dev/null
+++ b/lib/src/components/NxNavPillMenu/stateful/types.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import { ComponentPropsWithRef } from 'react';
+import PropTypes from 'prop-types';
+
+import { NavPillMenuItem } from '../types';
+
+export interface NxStatefulNavPillMenuProps extends ComponentPropsWithRef<'nav'> {
+ items: NavPillMenuItem[];
+ onItemChange?: (item: NavPillMenuItem) => void;
+ scrollBehavior?: 'smooth' | 'auto';
+ scrollOffset?: number;
+}
+
+export const nxStatefulNavPillMenuPropTypes = {
+ items: PropTypes.array.isRequired,
+ onItemChange: PropTypes.func,
+ scrollBehavior: PropTypes.oneOf(['smooth', 'auto']),
+ scrollOffset: PropTypes.number
+};
\ No newline at end of file
diff --git a/lib/src/components/NxNavPillMenu/types.ts b/lib/src/components/NxNavPillMenu/types.ts
new file mode 100644
index 0000000000..8529331e13
--- /dev/null
+++ b/lib/src/components/NxNavPillMenu/types.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2019-present Sonatype, Inc.
+ * This program and the accompanying materials are made available under
+ * the terms of the Eclipse Public License 2.0 which accompanies this
+ * distribution and is available at https://www.eclipse.org/legal/epl-2.0/.
+ */
+import { ComponentPropsWithRef } from 'react';
+import PropTypes from 'prop-types';
+
+export interface NavPillMenuItem {
+ id: string;
+ label: string;
+ href?: string;
+ scrollTarget?: string;
+ disabled?: boolean;
+}
+
+export interface NxNavPillMenuProps extends ComponentPropsWithRef<'nav'> {
+ items: NavPillMenuItem[];
+ onItemClick?: (item: NavPillMenuItem, event: React.MouseEvent) => void;
+ scrollBehavior?: 'smooth' | 'auto';
+ scrollOffset?: number;
+}
+
+export const nxNavPillMenuPropTypes = {
+ items: PropTypes.array.isRequired,
+ onItemClick: PropTypes.func,
+ scrollBehavior: PropTypes.oneOf(['smooth', 'auto']),
+ scrollOffset: PropTypes.number
+};
\ No newline at end of file
diff --git a/lib/src/index.ts b/lib/src/index.ts
index 8dc5929de4..c26ca4c47f 100644
--- a/lib/src/index.ts
+++ b/lib/src/index.ts
@@ -413,5 +413,9 @@ export { default as NxBreadcrumb, Props as NxBreadcrumbProps, Crumb as NxBreadcr
export { default as NxStatefulBreadcrumb, Props as NxStatefulBreadcrumbProps }
from './components/NxBreadcrumb/stateful/NxStatefulBreadcrumb';
+export { default as NxNavPillMenu, NxNavPillMenuProps, NavPillMenuItem }
+ from './components/NxNavPillMenu/NxNavPillMenu';
+export { default as NxStatefulNavPillMenu, NxStatefulNavPillMenuProps }
+ from './components/NxNavPillMenu/stateful/NxStatefulNavPillMenu';
export { default as NxSmallTag, Props as NxSmallTagProps } from './components/NxSmallTag/NxSmallTag';