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';