From 285f32b6def593a32d911711e4dce8bb3ce3d6e4 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 2 Dec 2025 14:53:33 +0530 Subject: [PATCH 1/6] feat: add navbar --- apps/www/src/app/examples/page.tsx | 82 ++++- apps/www/src/app/temp/page.tsx | 139 ++++++++ .../navbar/__tests__/navbar.test.tsx | 299 ++++++++++++++++++ packages/raystack/components/navbar/index.tsx | 1 + .../components/navbar/navbar-root.tsx | 33 ++ .../components/navbar/navbar-sections.tsx | 40 +++ .../components/navbar/navbar.module.css | 31 ++ .../raystack/components/navbar/navbar.tsx | 9 + packages/raystack/index.tsx | 1 + 9 files changed, 624 insertions(+), 11 deletions(-) create mode 100644 apps/www/src/app/temp/page.tsx create mode 100644 packages/raystack/components/navbar/__tests__/navbar.test.tsx create mode 100644 packages/raystack/components/navbar/index.tsx create mode 100644 packages/raystack/components/navbar/navbar-root.tsx create mode 100644 packages/raystack/components/navbar/navbar-sections.tsx create mode 100644 packages/raystack/components/navbar/navbar.module.css create mode 100644 packages/raystack/components/navbar/navbar.tsx diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 5eb06a1d6..58fde5848 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -14,6 +14,7 @@ import { IconButton, Indicator, InputField, + Navbar, Popover, RangePicker, Search, @@ -153,6 +154,76 @@ const Page = () => { /* comment */ /* ---------- __ */`} + {/* Navbar Examples */} + + Navbar Examples + + + + + Basic Navbar: + + + + Explore + + + + ) => + setSearch1(e.target.value) + } + onClear={() => setSearch1('')} + size='small' + style={{ width: '200px' }} + /> + + + + + + + + Sticky Navbar: + + + + Sticky Navigation + + + + + + + + + + + { You can filter team members by: -
    -
  • - Name -
  • -
  • - Role -
  • -
  • - Department -
  • -
diff --git a/apps/www/src/app/temp/page.tsx b/apps/www/src/app/temp/page.tsx new file mode 100644 index 000000000..bf31a0dbb --- /dev/null +++ b/apps/www/src/app/temp/page.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { Button, Flex, Navbar, Search, Text } from '@raystack/apsara'; +import { FilterIcon, OrganizationIcon } from '@raystack/apsara/icons'; +import { useState } from 'react'; + +export default function TempPage() { + const [searchValue, setSearchValue] = useState(''); + + // Dummy data + const dummyItems = [ + { + id: 1, + name: 'Item 1', + description: 'This is a description for item 1', + status: 'Active' + }, + { + id: 2, + name: 'Item 2', + description: 'This is a description for item 2', + status: 'Pending' + }, + { + id: 3, + name: 'Item 3', + description: 'This is a description for item 3', + status: 'Active' + }, + { + id: 4, + name: 'Item 4', + description: 'This is a description for item 4', + status: 'Inactive' + }, + { + id: 5, + name: 'Item 5', + description: 'This is a description for item 5', + status: 'Active' + } + ]; + + return ( + + + + + Explore + + + + ) => + setSearchValue(e.target.value) + } + onClear={() => setSearchValue('')} + size='small' + style={{ width: '200px' }} + /> + + + + + + + + Dummy Data + + + + {dummyItems.map(item => ( + + + + {item.name} + + + {item.status} + + + + {item.description} + + + ))} + + + + ); +} diff --git a/packages/raystack/components/navbar/__tests__/navbar.test.tsx b/packages/raystack/components/navbar/__tests__/navbar.test.tsx new file mode 100644 index 000000000..b91e6f8f5 --- /dev/null +++ b/packages/raystack/components/navbar/__tests__/navbar.test.tsx @@ -0,0 +1,299 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Navbar } from '../navbar'; +import { NavbarRootProps } from '../navbar-root'; +import styles from '../navbar.module.css'; + +const START_TEXT = 'Explore'; +const END_BUTTON_TEXT = 'Action'; +const TEST_ID = 'test-navbar'; + +const BasicNavbar = ({ + sticky = false, + children, + ...props +}: NavbarRootProps) => ( + + {children} + +); + +describe('Navbar', () => { + describe('Basic Rendering', () => { + it('renders navbar with children', () => { + render( + + + {START_TEXT} + + + + + + ); + + expect(screen.getByText(START_TEXT)).toBeInTheDocument(); + expect(screen.getByText(END_BUTTON_TEXT)).toBeInTheDocument(); + }); + + it('renders as nav element', () => { + const { container } = render(); + + const nav = container.querySelector('nav'); + expect(nav).toBeInTheDocument(); + }); + + it('applies root styles', () => { + const { container } = render(); + + const navbar = container.querySelector(`.${styles.root}`); + expect(navbar).toBeInTheDocument(); + }); + + it('has proper ARIA attributes', () => { + render(); + + const nav = screen.getByRole('navigation'); + expect(nav).toBeInTheDocument(); + expect(nav).toHaveAttribute('aria-label', 'Main navigation'); + }); + }); + + describe('Sticky Functionality', () => { + it('applies sticky attribute when sticky is true', () => { + const { container } = render(); + + const navbar = container.querySelector('[data-sticky="true"]'); + expect(navbar).toBeInTheDocument(); + }); + + it('does not apply sticky attribute when sticky is false', () => { + const { container } = render(); + + const navbar = container.querySelector('[data-sticky="true"]'); + expect(navbar).not.toBeInTheDocument(); + }); + + it('defaults to non-sticky', () => { + const { container } = render(); + + const navbar = container.querySelector('[data-sticky="true"]'); + expect(navbar).not.toBeInTheDocument(); + }); + }); + + describe('Navbar.Start', () => { + it('renders start section content', () => { + render( + + + {START_TEXT} + + + ); + + expect(screen.getByText(START_TEXT)).toBeInTheDocument(); + }); + + it('applies start styles', () => { + const { container } = render( + + + + ); + + const start = container.querySelector(`.${styles.start}`); + expect(start).toBeInTheDocument(); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render( + + + + ); + expect(ref).toHaveBeenCalled(); + }); + + it('applies custom className', () => { + const { container } = render( + + + + ); + + const start = container.querySelector('.custom-start-class'); + expect(start).toBeInTheDocument(); + }); + + it('applies custom styles', () => { + render( + + + + ); + + const start = screen.getByTestId('start'); + expect(start).toBeInTheDocument(); + }); + }); + + describe('Navbar.End', () => { + it('renders end section content', () => { + render( + + + + + + ); + + expect(screen.getByText(END_BUTTON_TEXT)).toBeInTheDocument(); + }); + + it('applies end styles', () => { + const { container } = render( + + + + ); + + const end = container.querySelector(`.${styles.end}`); + expect(end).toBeInTheDocument(); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render( + + + + ); + expect(ref).toHaveBeenCalled(); + }); + + it('applies custom className', () => { + const { container } = render( + + + + ); + + const end = container.querySelector('.custom-end-class'); + expect(end).toBeInTheDocument(); + }); + + it('applies custom styles', () => { + render( + + + + ); + + const end = screen.getByTestId('end'); + expect(end).toBeInTheDocument(); + }); + }); + + describe('Compound Component Structure', () => { + it('renders complete navbar with Start and End', () => { + render( + + + {START_TEXT} + + + + + + ); + + expect(screen.getByText(START_TEXT)).toBeInTheDocument(); + expect(screen.getByText(END_BUTTON_TEXT)).toBeInTheDocument(); + }); + + it('renders only Start section', () => { + render( + + + {START_TEXT} + + + ); + + expect(screen.getByText(START_TEXT)).toBeInTheDocument(); + }); + + it('renders only End section', () => { + render( + + + + + + ); + + expect(screen.getByText(END_BUTTON_TEXT)).toBeInTheDocument(); + }); + }); + + describe('Custom Props', () => { + it('forwards ref to root', () => { + const ref = vi.fn(); + render(); + expect(ref).toHaveBeenCalled(); + }); + + it('applies custom className to root', () => { + const { container } = render(); + + const navbar = container.querySelector('.custom-navbar'); + expect(navbar).toBeInTheDocument(); + }); + + it('applies custom data attributes', () => { + render(); + + const nav = screen.getByRole('navigation'); + expect(nav).toHaveAttribute('data-custom', 'test-value'); + }); + + it('applies custom id', () => { + render(); + + const nav = screen.getByRole('navigation'); + expect(nav).toHaveAttribute('id', 'my-navbar'); + }); + }); + + describe('Container Layout', () => { + it('renders container with flex layout', () => { + const { container } = render(); + + const containerEl = container.querySelector(`.${styles.container}`); + expect(containerEl).toBeInTheDocument(); + }); + + it('positions Start and End correctly', () => { + const { container } = render( + + Start + End + + ); + + const start = container.querySelector(`.${styles.start}`); + const end = container.querySelector(`.${styles.end}`); + + expect(start).toBeInTheDocument(); + expect(end).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/raystack/components/navbar/index.tsx b/packages/raystack/components/navbar/index.tsx new file mode 100644 index 000000000..ae99107e1 --- /dev/null +++ b/packages/raystack/components/navbar/index.tsx @@ -0,0 +1 @@ +export { Navbar } from './navbar'; diff --git a/packages/raystack/components/navbar/navbar-root.tsx b/packages/raystack/components/navbar/navbar-root.tsx new file mode 100644 index 000000000..e3ac40678 --- /dev/null +++ b/packages/raystack/components/navbar/navbar-root.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { cva } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from 'react'; +import { Flex } from '../flex'; +import styles from './navbar.module.css'; + +const root = cva(styles.root); + +export interface NavbarRootProps extends ComponentPropsWithoutRef<'nav'> { + sticky?: boolean; +} + +export const NavbarRoot = forwardRef, NavbarRootProps>( + ({ className, sticky = false, children, ...props }, ref) => { + return ( + + ); + } +); + +NavbarRoot.displayName = 'Navbar'; diff --git a/packages/raystack/components/navbar/navbar-sections.tsx b/packages/raystack/components/navbar/navbar-sections.tsx new file mode 100644 index 000000000..d2937809c --- /dev/null +++ b/packages/raystack/components/navbar/navbar-sections.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, forwardRef } from 'react'; +import { Flex } from '../flex'; +import styles from './navbar.module.css'; + +export const NavbarStart = forwardRef< + HTMLDivElement, + ComponentPropsWithoutRef<'div'> +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +NavbarStart.displayName = 'Navbar.Start'; + +export const NavbarEnd = forwardRef< + HTMLDivElement, + ComponentPropsWithoutRef<'div'> +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +NavbarEnd.displayName = 'Navbar.End'; diff --git a/packages/raystack/components/navbar/navbar.module.css b/packages/raystack/components/navbar/navbar.module.css new file mode 100644 index 000000000..53b59bb40 --- /dev/null +++ b/packages/raystack/components/navbar/navbar.module.css @@ -0,0 +1,31 @@ +.root { + display: flex; + width: 100%; + padding: var(--rs-space-4) var(--rs-space-6); + background: var(--rs-color-background-base-primary); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.06), 0 4px 4px -1px rgba(0, 0, 0, 0.02); + box-sizing: border-box; + min-height: 48px; +} + +.root[data-sticky='true'] { + position: sticky; + top: 0; + z-index: 100; +} + +.container { + width: 100%; + align-items: center; +} + +.start { + flex: 0 0 auto; +} + +.end { + flex: 0 0 auto; + margin-left: auto; +} + diff --git a/packages/raystack/components/navbar/navbar.tsx b/packages/raystack/components/navbar/navbar.tsx new file mode 100644 index 000000000..0277abcff --- /dev/null +++ b/packages/raystack/components/navbar/navbar.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { NavbarRoot } from './navbar-root'; +import { NavbarEnd, NavbarStart } from './navbar-sections'; + +export const Navbar = Object.assign(NavbarRoot, { + Start: NavbarStart, + End: NavbarEnd +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 7813f9f12..2c48fec64 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -38,6 +38,7 @@ export { InputField } from './components/input-field'; export { Label } from './components/label'; export { Link } from './components/link'; export { List } from './components/list'; +export { Navbar } from './components/navbar'; export { Popover } from './components/popover'; export { Radio } from './components/radio'; export { Search } from './components/search'; From ceaf3f5dacfe7a079867e0ca8f8abc872324cae3 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 2 Dec 2025 14:53:57 +0530 Subject: [PATCH 2/6] style: update style --- packages/raystack/components/navbar/navbar.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/raystack/components/navbar/navbar.module.css b/packages/raystack/components/navbar/navbar.module.css index 53b59bb40..bf533f449 100644 --- a/packages/raystack/components/navbar/navbar.module.css +++ b/packages/raystack/components/navbar/navbar.module.css @@ -4,12 +4,13 @@ padding: var(--rs-space-4) var(--rs-space-6); background: var(--rs-color-background-base-primary); border-bottom: 0.5px solid var(--rs-color-border-base-primary); - box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.06), 0 4px 4px -1px rgba(0, 0, 0, 0.02); + box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.06), 0 4px 4px -1px + rgba(0, 0, 0, 0.02); box-sizing: border-box; min-height: 48px; } -.root[data-sticky='true'] { +.root[data-sticky="true"] { position: sticky; top: 0; z-index: 100; @@ -28,4 +29,3 @@ flex: 0 0 auto; margin-left: auto; } - From ebbae36edce7c2bfd60090f54f09874c044ecf59 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 2 Dec 2025 16:37:36 +0530 Subject: [PATCH 3/6] feat: add accessibility --- .../navbar/__tests__/navbar.test.tsx | 88 +++++++++++++++++++ .../components/navbar/navbar-root.tsx | 29 +++++- .../components/navbar/navbar-sections.tsx | 74 ++++++++++------ 3 files changed, 161 insertions(+), 30 deletions(-) diff --git a/packages/raystack/components/navbar/__tests__/navbar.test.tsx b/packages/raystack/components/navbar/__tests__/navbar.test.tsx index b91e6f8f5..9581d5ec6 100644 --- a/packages/raystack/components/navbar/__tests__/navbar.test.tsx +++ b/packages/raystack/components/navbar/__tests__/navbar.test.tsx @@ -57,6 +57,42 @@ describe('Navbar', () => { expect(nav).toBeInTheDocument(); expect(nav).toHaveAttribute('aria-label', 'Main navigation'); }); + + it('supports custom aria-label', () => { + render(); + + const nav = screen.getByRole('navigation'); + expect(nav).toHaveAttribute('aria-label', 'Primary site navigation'); + }); + + it('supports aria-labelledby', () => { + render( + <> + + + + ); + + const nav = screen.getByRole('navigation'); + expect(nav).toHaveAttribute('aria-labelledby', 'nav-heading'); + expect(nav).not.toHaveAttribute('aria-label'); + }); + + it('prioritizes aria-labelledby over default aria-label', () => { + render( + <> + + + + ); + + const nav = screen.getByRole('navigation'); + expect(nav).toHaveAttribute('aria-labelledby', 'nav-heading'); + expect(nav).toHaveAttribute('aria-label', 'Custom label'); + }); }); describe('Sticky Functionality', () => { @@ -95,6 +131,32 @@ describe('Navbar', () => { expect(screen.getByText(START_TEXT)).toBeInTheDocument(); }); + it('supports aria-label for start section', () => { + render( + + + {START_TEXT} + + + ); + + const start = screen.getByRole('group', { + name: 'Brand and navigation links' + }); + expect(start).toBeInTheDocument(); + }); + + it('does not add role when aria-label is not provided', () => { + const { container } = render( + + + + ); + + const start = container.querySelector('[data-testid="start"]'); + expect(start).not.toHaveAttribute('role'); + }); + it('applies start styles', () => { const { container } = render( @@ -155,6 +217,32 @@ describe('Navbar', () => { expect(screen.getByText(END_BUTTON_TEXT)).toBeInTheDocument(); }); + it('supports aria-label for end section', () => { + render( + + + + + + ); + + const end = screen.getByRole('group', { + name: 'User actions and settings' + }); + expect(end).toBeInTheDocument(); + }); + + it('does not add role when aria-label is not provided', () => { + const { container } = render( + + + + ); + + const end = container.querySelector('[data-testid="end"]'); + expect(end).not.toHaveAttribute('role'); + }); + it('applies end styles', () => { const { container } = render( diff --git a/packages/raystack/components/navbar/navbar-root.tsx b/packages/raystack/components/navbar/navbar-root.tsx index e3ac40678..e0648c608 100644 --- a/packages/raystack/components/navbar/navbar-root.tsx +++ b/packages/raystack/components/navbar/navbar-root.tsx @@ -9,17 +9,42 @@ const root = cva(styles.root); export interface NavbarRootProps extends ComponentPropsWithoutRef<'nav'> { sticky?: boolean; + /** + * Accessible label for the navigation. If not provided, defaults to "Main navigation". + * Use this to provide a more specific description of the navbar's purpose. + */ + 'aria-label'?: string; + /** + * ID of an element that labels the navigation. Use this when you have a visible heading + * that describes the navbar. + */ + 'aria-labelledby'?: string; } export const NavbarRoot = forwardRef, NavbarRootProps>( - ({ className, sticky = false, children, ...props }, ref) => { + ( + { + className, + sticky = false, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + children, + ...props + }, + ref + ) => { + // Only set default aria-label if neither aria-label nor aria-labelledby is provided + const navAriaLabel = + ariaLabel || (!ariaLabelledBy ? 'Main navigation' : undefined); + return (