diff --git a/.gitignore b/.gitignore index 0efe208..26caa16 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,11 @@ build-storybook.log .DS_Store - +.clinerules/byterover-rules.md +.kilocode/rules/byterover-rules.md +.roo/rules/byterover-rules.md +.windsurf/rules/byterover-rules.md +.cursor/rules/byterover-rules.mdc +.kiro/steering/byterover-rules.md +.qoder/rules/byterover-rules.md +.augment/rules/byterover-rules.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..420ca60 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +[byterover-mcp] + +[byterover-mcp] + +You are given two tools from Byterover MCP server, including + +## 1. `byterover-store-knowledge` + +You `MUST` always use this tool when: + +- Learning new patterns, APIs, or architectural decisions from the codebase +- Encountering error solutions or debugging techniques +- Finding reusable code patterns or utility functions +- Completing any significant task or plan implementation + +## 2. `byterover-retrieve-knowledge` + +You `MUST` always use this tool when: + +- Starting any new task or implementation to gather relevant context +- Before making architectural decisions to understand existing patterns +- When debugging issues to check for previous solutions +- Working with unfamiliar parts of the codebase diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..420ca60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +[byterover-mcp] + +[byterover-mcp] + +You are given two tools from Byterover MCP server, including + +## 1. `byterover-store-knowledge` + +You `MUST` always use this tool when: + +- Learning new patterns, APIs, or architectural decisions from the codebase +- Encountering error solutions or debugging techniques +- Finding reusable code patterns or utility functions +- Completing any significant task or plan implementation + +## 2. `byterover-retrieve-knowledge` + +You `MUST` always use this tool when: + +- Starting any new task or implementation to gather relevant context +- Before making architectural decisions to understand existing patterns +- When debugging issues to check for previous solutions +- Working with unfamiliar parts of the codebase diff --git a/package-lock.json b/package-lock.json index 2927e5e..f36bec2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rolemodel/betanxt-design-system", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rolemodel/betanxt-design-system", - "version": "1.0.0", + "version": "1.1.0", "license": "UNLICENSED", "devDependencies": { "@chromatic-com/storybook": "^4.1.1", diff --git a/package.json b/package.json index c869240..104c8bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rolemodel/betanxt-design-system", - "version": "1.0.0", + "version": "1.1.0", "description": "The BetaNXT design system for MUI.", "type": "module", "exports": { diff --git a/src/components/app-bar/BNAnimatedMenuIcon.tsx b/src/components/app-bar/BNAnimatedMenuIcon.tsx index 56010ce..37ae2d4 100644 --- a/src/components/app-bar/BNAnimatedMenuIcon.tsx +++ b/src/components/app-bar/BNAnimatedMenuIcon.tsx @@ -1,20 +1,30 @@ 'use client' -import { type MouseEvent, type ReactNode, useState } from 'react' +import { type MouseEvent, type ReactNode, memo, useState } from 'react' +import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded' +import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded' +import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded' import { Avatar, + Box, + ClickAwayListener, + Divider, IconButton, ListItemIcon, ListItemText, ListSubheader, - Menu, + MenuList, MenuItem as MuiMenuItem, + Paper, + Popper, + ToggleButton, + ToggleButtonGroup, useMediaQuery, useTheme, } from '@mui/material' import type { MenuItemProps } from '@mui/material' -import { type CSSObject, keyframes, styled } from '@mui/material/styles' +import { type CSSObject, keyframes, styled, useColorScheme } from '@mui/material/styles' // Define Keyframes const bottombarOpen = keyframes` @@ -50,6 +60,10 @@ export interface MenuItem extends Omit { ariaLabel?: string hasSubeader?: boolean subeaderLabel?: string + /** Optional custom content to render inside the menu list */ + render?: React.ReactNode + /** Marker used by BNAppBar to request theme toggle injection */ + isThemeToggle?: boolean } export interface AvatarProps { @@ -59,6 +73,7 @@ export interface AvatarProps { } export interface BNAnimatedMenuIconProps { + open?: boolean menuItems: MenuItem[] onDrawerToggle?: () => void drawerOpen?: boolean @@ -66,6 +81,23 @@ export interface BNAnimatedMenuIconProps { useAnimatedIconOnly?: boolean LinkComponent?: React.ElementType subheaderLabel?: string + hasAppSwitcher?: boolean + slots?: { + popper?: React.ElementType + paper?: React.ElementType + menuList?: React.ElementType + menuItem?: React.ElementType + iconButton?: React.ElementType + avatar?: React.ElementType + } + slotProps?: { + popper?: React.ComponentProps + paper?: React.ComponentProps + menuList?: React.ComponentProps + menuItem?: React.ComponentProps + iconButton?: React.ComponentProps + avatar?: React.ComponentProps + } } const StyledMenuIcon = styled('button', { @@ -116,7 +148,44 @@ const StyledMenuIcon = styled('button', { }) ) +// Extracted to avoid re-creating the component on each BNAnimatedMenuIcon render +const ThemeToggleItem = memo(() => { + const { mode, setMode } = useColorScheme() + const handleChange = ( + _event: React.MouseEvent, + nextMode: 'light' | 'dark' | 'system' | null + ) => { + if (nextMode) setMode(nextMode) + } + return ( + + + + + Light + + + + System + + + + Dark + + + + ) +}) + export const BNAnimatedMenuIcon = ({ + open, subheaderLabel, menuItems, onDrawerToggle, @@ -124,10 +193,18 @@ export const BNAnimatedMenuIcon = ({ avatar, useAnimatedIconOnly = false, LinkComponent, + hasAppSwitcher, + slots = {}, + slotProps = {}, }: BNAnimatedMenuIconProps) => { const [menuAnchorEl, setMenuAnchorEl] = useState(null) const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('md')) + const appSwitcherTopOffset = (() => { + const anyTheme = theme as unknown as { layout?: { appSwitcher?: { top?: number } } } + const top = anyTheme.layout?.appSwitcher?.top + return typeof top === 'number' ? top : 0 + })() const handleMenuClick = (event: MouseEvent) => { if ((isMobile || useAnimatedIconOnly) && onDrawerToggle) { @@ -150,6 +227,7 @@ export const BNAnimatedMenuIcon = ({ backgroundImage: 'var(--Paper-overlay)', lineHeight: '2.5rem', }) + // Use animated icon for mobile OR when useAnimatedIconOnly is true if (isMobile || useAnimatedIconOnly) { return ( @@ -165,30 +243,63 @@ export const BNAnimatedMenuIcon = ({
{/* Show dropdown menu when using animated icon only on desktop */} - {useAnimatedIconOnly && !isMobile && ( - - {menuItems.map((item, index) => { - const { label, icon, onClick, ...menuItemProps } = item - return ( - handleMenuItemClick(item)} - {...menuItemProps} - > - {icon && {icon}} - - - ) - })} - - )} + {useAnimatedIconOnly && + !isMobile && + (() => { + const PopperComp = slots.popper ?? Popper + const PaperComp = slots.paper ?? Paper + const MenuListComp = slots.menuList ?? MenuList + const MenuItemComp = slots.menuItem ?? MuiMenuItem + return ( + + + + + {menuItems.map((item, index) => { + const { label, icon, onClick, ...menuItemProps } = item + const isDividerOnly = + (menuItemProps as any)?.divider === true && + (!label || String(label).trim() === '') + if (isDividerOnly) { + return + } + return ( + handleMenuItemClick(item)} + {...(slotProps.menuItem || {})} + {...menuItemProps} + > + {icon && {icon}} + + + ) + })} + + + + + ) + })()} ) } @@ -196,63 +307,106 @@ export const BNAnimatedMenuIcon = ({ // Default desktop behavior with avatar return ( <> - - - {avatar.children} - - - - {typeof subheaderLabel === 'string' && subheaderLabel.trim().length > 0 && ( - {subheaderLabel} - )} - {menuItems.map((item, index) => { - const { label, icon, onClick: _onClick, ...menuItemProps } = item - return ( - handleMenuItemClick(item)} - {...menuItemProps} + {(() => { + const IconButtonComp = slots.iconButton ?? IconButton + const AvatarComp = slots.avatar ?? Avatar + return ( + + + {avatar.children} + + + ) + })()} + {(() => { + const PopperComp = slots.popper ?? Popper + const PaperComp = slots.paper ?? Paper + const MenuListComp = slots.menuList ?? MenuList + const MenuItemComp = slots.menuItem ?? MuiMenuItem + return ( + + - {icon && {icon}} - - - ) - })} - + <> + {typeof subheaderLabel === 'string' && + subheaderLabel.trim().length > 0 && ( + {subheaderLabel} + )} + + + {menuItems.map((item, index) => { + const { label, icon, onClick: _onClick, ...menuItemProps } = item + const isDividerOnly = + (menuItemProps as any)?.divider === true && + (!label || String(label).trim() === '') + if (isDividerOnly) { + return + } + if ((item as any).isThemeToggle) { + return + } + return ( + handleMenuItemClick(item)} + {...(slotProps.menuItem || {})} + {...menuItemProps} + > + {icon && {icon}} + + + ) + })} + + + + + + ) + })()} ) } diff --git a/src/components/app-bar/BNAppBar.tsx b/src/components/app-bar/BNAppBar.tsx index 4709af6..efc176c 100644 --- a/src/components/app-bar/BNAppBar.tsx +++ b/src/components/app-bar/BNAppBar.tsx @@ -1,7 +1,14 @@ import React from 'react' +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' import { + ClickAwayListener, + Divider, + MenuList, AppBar as MuiAppBar, + MenuItem as MuiMenuItem, + Paper, + Popper, Stack, Tab, Tabs, @@ -10,9 +17,19 @@ import { useMediaQuery, useTheme, } from '@mui/material' +import type { + MenuListProps, + MenuItemProps as MuiMenuItemProps, + PaperProps, + PopperProps, + TabProps, + TabsProps, +} from '@mui/material' +import type { MenuItem as BNMenuItem } from './BNAnimatedMenuIcon' import { type AvatarProps, BNAnimatedMenuIcon, type MenuItem } from './BNAnimatedMenuIcon' import { BNAppBarDrawer } from './BNAppBarDrawer' +import type { BNAppBarDrawerMenuItem } from './BNAppBarDrawer' const getLogoImgStyles = (theme: any, src?: string): React.CSSProperties => ({ display: 'inline-flex', @@ -32,6 +49,8 @@ type BNAppBarLink = { disabled?: boolean href?: string to?: string | { pathname: string; search?: string; hash?: string; state?: any } + // When provided, this tab becomes a dropdown trigger and is not directly navigable + children?: BNMenuItem[] } export interface BNAppBarProps { @@ -43,17 +62,30 @@ export interface BNAppBarProps { avatar?: AvatarProps menuItems?: MenuItem[] menuSubheaderLabel?: string + includeThemeToggle?: boolean 'aria-label'?: string children?: React.ReactNode slots?: { logoComponent?: React.ElementType logoImg?: React.ElementType end?: React.ElementType + tabsContainer?: React.ComponentType + tab?: React.ComponentType + tabMenuPopper?: React.ComponentType + tabMenuPaper?: React.ComponentType + tabMenuList?: React.ComponentType + tabMenuItem?: React.ComponentType } slotProps?: { logoComponent?: React.ComponentProps logoImg?: React.ImgHTMLAttributes end?: React.ComponentProps + tabsContainer?: Partial + tab?: Partial + tabMenuPopper?: Partial + tabMenuPaper?: Partial + tabMenuList?: Partial + tabMenuItem?: Partial } } @@ -66,6 +98,7 @@ export function BNAppBar({ menuItems, avatar, menuSubheaderLabel, + includeThemeToggle, 'aria-label': ariaLabel, children, slots = {}, @@ -74,11 +107,77 @@ export function BNAppBar({ const [drawerOpen, setDrawerOpen] = React.useState(false) const theme = useTheme() const isDesktop = useMediaQuery(theme.breakpoints.up('md')) + const titleId = React.useId() + + // Dropdown tab state + const [tabMenuAnchorEl, setTabMenuAnchorEl] = React.useState(null) + const [openTabValue, setOpenTabValue] = React.useState(null) + + const handleOpenTabMenu = (event: React.MouseEvent, value: string) => { + setTabMenuAnchorEl(event.currentTarget) + setOpenTabValue((prev) => (prev === value ? null : value)) + } + + const handleCloseTabMenu = () => { + setOpenTabValue(null) + setTabMenuAnchorEl(null) + } const handleDrawerToggle = () => { setDrawerOpen((prev) => !prev) } + // Process menu items to inject theme toggle second-to-last (logout last) when requested + const processedMenuItems = React.useMemo(() => { + if (!menuItems || menuItems.length === 0) return menuItems + + const items = [...menuItems] + if (includeThemeToggle && !items.some((i) => i.isThemeToggle === true)) { + const toggleItem: MenuItem = { label: 'Theme', isThemeToggle: true } + items.push(toggleItem) + } + const toggleIndex = items.findIndex((i) => (i as any).isThemeToggle === true) + if (toggleIndex === -1) return items + + const toggleItem = items.splice(toggleIndex, 1)[0] + if (!toggleItem) return items + const logoutIndexAfterRemoval = items.findIndex((i) => /logout/i.test(i.label)) + if (logoutIndexAfterRemoval > -1) { + // If a divider is immediately before Logout, insert before that divider + const dividerBeforeLogoutIndex = logoutIndexAfterRemoval - 1 + const hasDividerBeforeLogout = + dividerBeforeLogoutIndex >= 0 && + (items[dividerBeforeLogoutIndex] as any)?.divider === true + const insertAt = hasDividerBeforeLogout + ? dividerBeforeLogoutIndex + : logoutIndexAfterRemoval + items.splice(insertAt, 0, toggleItem) + } else { + // No logout present; append at end + items.push(toggleItem) + } + return items + }, [menuItems, includeThemeToggle]) + + const drawerMenuItems: BNAppBarDrawerMenuItem[] | undefined = React.useMemo(() => { + if (!processedMenuItems) return undefined + return processedMenuItems.map((item) => { + if (item.isThemeToggle) { + return { isThemeToggle: true } + } + if (item.divider === true) { + return { divider: true } + } + return { + label: item.label, + icon: item.icon, + disabled: item.disabled, + onClick: item.onClick, + to: item.to as any, + } + }) as BNAppBarDrawerMenuItem[] + }, [processedMenuItems]) + return ( + {title} )} {tabs && isDesktop && ( - - {tabs.map((tab) => ( - - ))} - + <> + {(() => { + const TabsComponent: React.ComponentType = + slots.tabsContainer ?? Tabs + const TabComponent: React.ComponentType = slots.tab ?? Tab + return ( + + {tabs.map((tab) => { + const hasChildren = + Array.isArray(tab.children) && tab.children.length > 0 + if (!hasChildren) { + const { + children: _dropdownItems, + href: _href, + to: _to, + ...safeTabProps + } = tab as any + return ( + + ) + } + return ( + } + iconPosition="end" + onClick={(e: React.MouseEvent) => + handleOpenTabMenu(e, tab.value) + } + {...(slotProps.tab || {})} + /> + ) + })} + + ) + })()} + + {/* Dropdown menu for tabs using Popper */} + {(() => { + const PopperComponent: React.ComponentType = + slots.tabMenuPopper ?? Popper + const PaperComponent: React.ComponentType = + slots.tabMenuPaper ?? Paper + const MenuListComponent: React.ComponentType = + slots.tabMenuList ?? MenuList + const MenuItemComponent: React.ComponentType = + slots.tabMenuItem ?? MuiMenuItem + const current = tabs.find((t) => t.value === openTabValue) + return ( + + + + + {current?.children?.map((item, index) => { + const { label, icon, onClick, ...menuItemProps } = item + const isDividerOnly = + (menuItemProps as any)?.divider === true && + (!label || String(label).trim() === '') + if (isDividerOnly) { + return + } + return ( + { + item.onClick?.() + handleCloseTabMenu() + }} + LinkComponent={LinkComponent} + {...(slotProps.tabMenuItem || {})} + {...menuItemProps} + > + {icon} + {label} + + ) + })} + + + + + ) + })()} + )} - {avatar && menuItems && ( + {avatar && processedMenuItems && ( )} {slots.end && React.createElement(slots.end, slotProps.end)} - {avatar && menuItems && ( + {avatar && processedMenuItems && ( void - to?: string | { pathname: string; search?: string; hash?: string; state?: any } + to?: DrawerLink + divider?: false + isThemeToggle?: false +} + +type DrawerDividerItem = { + divider: true + label?: string +} + +type DrawerThemeToggleItem = { + isThemeToggle: true + label?: string } +export type BNAppBarDrawerMenuItem = + | DrawerActionItem + | DrawerDividerItem + | DrawerThemeToggleItem + export interface BNAppBarDrawerProps { open: boolean onClose: () => void @@ -51,6 +79,61 @@ export const BNAppBarDrawer = ({ LinkComponent, hasAppSwitcher = false, }: BNAppBarDrawerProps) => { + const ThemeToggleItem = () => { + const { mode, setMode } = useColorScheme() + const handleChange = ( + _event: React.MouseEvent, + nextMode: 'light' | 'dark' | 'system' | null + ) => { + if (nextMode) setMode(nextMode) + } + return ( + + + + + Light + + + + System + + + + Dark + + + + ) + } + const sanitizedMenuItems: BNAppBarDrawerMenuItem[] = React.useMemo(() => { + const items = menuItems || [] + const compact: BNAppBarDrawerMenuItem[] = [] + let lastWasDivider = false + for (const item of items) { + const isDivider = 'divider' in item && item.divider === true + if (isDivider) { + if (compact.length === 0 || lastWasDivider) continue + compact.push({ divider: true } as DrawerDividerItem) + lastWasDivider = true + } else { + compact.push(item) + lastWasDivider = false + } + } + const last = compact[compact.length - 1] + if (last && 'divider' in last && last.divider === true) { + compact.pop() + } + return compact + }, [menuItems]) return ( 0 && ( <> - {tabs.map((tab) => ( - + {tabs.map((tab) => { + const children = (tab as any)?.children + const hasChildren = Array.isArray(children) && children.length > 0 + if (!hasChildren) { + return ( + + { + tab.onClick?.() + onTabClick?.(tab.value) + }} + role="button" + aria-label={`Navigate to ${tab.label}`} + tabIndex={0} + {...tab} + > + + + + ) + } + // Non-clickable parent label and clickable child entries + return ( + + + + {tab.label} + + } + /> + + {children.map((item: any, idx: number) => { + if ( + item?.divider === true && + (!item.label || String(item.label).trim() === '') + ) { + return ( + + ) + } + return ( + + { + item.onClick?.() + onMenuItemClick?.(item.label) + }} + role="button" + aria-label={item.label} + tabIndex={0} + sx={{ pl: 3 }} + {...item} + > + {item.icon && {item.icon}} + + + + ) + })} + + ) + })} + + {sanitizedMenuItems.length > 0 && } + + )} + + {/* Account Menu Section */} + {sanitizedMenuItems.length > 0 && ( + + {sanitizedMenuItems.map((item, index) => { + if ('divider' in item && item.divider === true) { + return + } + if ('isThemeToggle' in item && item.isThemeToggle === true) { + return + } + const action = item as DrawerActionItem + return ( + { - tab.onClick?.() - onTabClick?.(tab.value) + action.onClick?.() + onMenuItemClick?.(action.label) }} role="button" - aria-label={`Navigate to ${tab.label}`} + aria-label={action.label} tabIndex={0} - {...tab} > - + {action.icon && {action.icon}} + - ))} - - {menuItems.length > 0 && } - - )} - - {/* Account Menu Section */} - {menuItems.length > 0 && ( - - {menuItems.map((item, index) => ( - - { - item.onClick?.() - onMenuItemClick?.(item.label) - }} - role="button" - aria-label={item.label} - tabIndex={0} - {...item} - > - {item.icon && {item.icon}} - - - - ))} + ) + })} )} diff --git a/src/components/app-header/BNAppHeader.mdx b/src/components/app-header/BNAppHeader.mdx new file mode 100644 index 0000000..a2cab56 --- /dev/null +++ b/src/components/app-header/BNAppHeader.mdx @@ -0,0 +1,12 @@ +import { Canvas, Controls, Meta, Stories, Story } from '@storybook/addon-docs/blocks' + +import meta, * as BNAppHeaderStories from './BNAppHeader.stories' + + + +# BNAppHeader + +The `BNAppHeader` is a light wrapper around Material UI's `AppBar` component, with composible helper components +for easy composibility of application headers. + + diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx new file mode 100644 index 0000000..05b885f --- /dev/null +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -0,0 +1,164 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import LogoutIconOutlined from '@mui/icons-material/LogoutOutlined' +import { + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + MenuItem, + MenuList, + Typography, +} from '@mui/material' + +import { BNLogo } from '../BNLogo' +import BNAppHeader from './BNAppHeader' +import { BNAppSwitcher } from './BNAppSwitcher' +import { ThemeToggle } from './ThemeToggle' +import { BNAvatarMenu } from './avatar-menu/BNAvatarMenu' +import { BNHamburgerMenu } from './hamburger-menu/BNHamburgerMenu' +import { BNListItem } from './menu/BNListItem' +import { BNListItemWithChildren } from './menu/BNListItemWithChildren' + +const meta = { + title: 'Custom Components/App Header/BNAppHeader', + component: BNAppHeader, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + Client Name + + + + + + + + + + My App + + + + + + + + + Proxy + + + Bankruptcy + + + Reorg + + + + + + + + + Account + + Profile + + + Settings + + + + + + + Logout + + + + + + + + + + + + + + + + + + + + Account}> + + + } + /> + + + + + + + + ), +} + +export const WithSVGLogo: Story = { + render: () => ( + + + + + + + + ), +} + +export const WithBNLogo: Story = { + render: () => ( + + + + + + ), +} + +export const WithControlBar: Story = { + render: () => ( + + + Client Name + + + + + + + + ), +} diff --git a/src/components/app-header/BNAppHeader.tsx b/src/components/app-header/BNAppHeader.tsx new file mode 100644 index 0000000..3d92043 --- /dev/null +++ b/src/components/app-header/BNAppHeader.tsx @@ -0,0 +1,92 @@ +import { type ReactNode } from 'react' + +import { + AppBar, + Box, + type BoxProps, + Stack, + type TabsProps, + Toolbar, + type ToolbarProps, + Typography, + type TypographyProps, + useMediaQuery, + useTheme, +} from '@mui/material' + +import { BNControlBar } from './BNControlBar' +import { Tab, TabWithSubMenu, Tabs } from './header-tabs' + +function BNAppHeader({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ) +} + +BNAppHeader.Toolbar = (props: ToolbarProps) => { + return ( + + {props.children} + + ) +} + +BNAppHeader.Title = (props: TypographyProps) => { + return ( + + {props.children} + + ) +} + +BNAppHeader.Logo = (props: BoxProps<'img'>) => { + const { src } = props + return ( + ({ + display: 'inline-flex', + alignItems: 'center', + height: 44, + ...(src && + !src.endsWith('.svg') && { + backgroundColor: theme.vars.palette.common.white, + padding: theme.spacing(0.5), + borderRadius: 1, + }), + })} + /> + ) +} + +BNAppHeader.Section = ({ children }: { children?: ReactNode }) => { + return ( + + {children} + + ) +} + +BNAppHeader.DesktopOnlySection = ({ children }: { children?: ReactNode }) => { + const theme = useTheme() + const isDesktop = useMediaQuery(theme.breakpoints.up('md')) + return isDesktop ? children : null +} + +BNAppHeader.MobileOnlySection = ({ children }: { children?: ReactNode }) => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + return isMobile ? children : null +} + +BNAppHeader.ControlBar = BNControlBar +BNAppHeader.Tab = Tab +BNAppHeader.Tabs = Tabs +BNAppHeader.TabWithSubMenu = TabWithSubMenu + +export default BNAppHeader diff --git a/src/components/app-header/BNAppSwitcher.tsx b/src/components/app-header/BNAppSwitcher.tsx new file mode 100644 index 0000000..3eccc13 --- /dev/null +++ b/src/components/app-header/BNAppSwitcher.tsx @@ -0,0 +1,78 @@ +import { type ReactNode, useState } from 'react' + +import AppsIcon from '@mui/icons-material/Apps' +import { Button, Menu, MenuItem, type MenuItemProps, styled } from '@mui/material' + +export function BNAppSwitcher({ + currentAppName, + children, +}: { + currentAppName: string + children: ReactNode +}) { + const [anchorEl, setAnchorEl] = useState(null) + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + return ( + <> + + ({ + '& .MuiPaper-root': { + backgroundColor: theme.vars.palette.appSwitcher.background, + backgroundImage: 'none', + color: theme.vars.palette.appSwitcher.contrastText, + }, + })} + > + {children} + + + ) +} + +const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ + width: '100%', + '&:hover': { + backgroundColor: theme.vars.palette.appSwitcher.hover, + }, +})) + +BNAppSwitcher.Item = ( + props: { name: string } & MenuItemProps +) => { + return ( + + {props.name} + + ) +} diff --git a/src/components/app-header/BNControlBar.tsx b/src/components/app-header/BNControlBar.tsx new file mode 100644 index 0000000..a0d4eaf --- /dev/null +++ b/src/components/app-header/BNControlBar.tsx @@ -0,0 +1,22 @@ +import { type ReactNode } from 'react' + +import { Box } from '@mui/material' + +export function BNControlBar({ children }: { children?: ReactNode }) { + return ( + ({ + px: 2, + backgroundColor: + theme.vars.palette.appSwitcher?.background || theme.palette.primary.main, + minHeight: theme.layout?.appSwitcherHeight || 48, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + color: theme.palette.common.white, + })} + > + {children} + + ) +} diff --git a/src/components/app-header/ThemeToggle.tsx b/src/components/app-header/ThemeToggle.tsx new file mode 100644 index 0000000..07320e6 --- /dev/null +++ b/src/components/app-header/ThemeToggle.tsx @@ -0,0 +1,41 @@ +import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded' +import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded' +import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded' +import { Box, ToggleButton, ToggleButtonGroup, useColorScheme } from '@mui/material' + +export function ThemeToggle() { + const { mode, setMode } = useColorScheme() + + const handleChange = ( + _event: React.MouseEvent, + nextMode: 'light' | 'dark' | 'system' | null + ) => { + if (nextMode) setMode(nextMode) + } + + return ( + + + + + Light + + + + System + + + + Dark + + + + ) +} diff --git a/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx b/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx new file mode 100644 index 0000000..4d4fe69 --- /dev/null +++ b/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' + +import LogoutIconOutlined from '@mui/icons-material/LogoutOutlined' +import { Divider, ListItemIcon, ListItemText, MenuItem, MenuList } from '@mui/material' + +import { ThemeToggle } from '../ThemeToggle' +import { BNAvatarMenu } from './BNAvatarMenu' + +const meta = { + title: 'Custom Components/App Header/BNAvatarMenu', + component: BNAvatarMenu, + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + Account + + Profile + + + Settings + + + + + + + Logout + + + + ), + play: async ({ canvas, userEvent, step }) => { + await step('Open avatar menu', async () => { + const menuIcon = canvas.getByRole('button', { name: 'Open Menu' }) + await userEvent.click(menuIcon) + await expect(menuIcon).toHaveAttribute('aria-expanded', 'true') + + const popper = await within(document.body).findByRole('navigation') + await expect(popper).toBeVisible() + await expect(popper).toHaveTextContent('Account') + await expect(popper).toHaveTextContent('Profile') + await expect(popper).toHaveTextContent('Settings') + await expect(popper).toHaveTextContent('Logout') + }) + + await step('Close menu by clicking anywhere on the page (click away)', async () => { + const popper = await within(document.body).findByRole('navigation') + await userEvent.click(document.body) + await expect(popper).not.toBeVisible() + }) + }, +} + +export const WithNoImage: Story = { + render: () => ( + + + + ), +} diff --git a/src/components/app-header/avatar-menu/BNAvatarMenu.tsx b/src/components/app-header/avatar-menu/BNAvatarMenu.tsx new file mode 100644 index 0000000..d7ce1cc --- /dev/null +++ b/src/components/app-header/avatar-menu/BNAvatarMenu.tsx @@ -0,0 +1,78 @@ +import { type MouseEvent, type ReactNode, useState } from 'react' + +import { + Avatar, + type AvatarProps, + ClickAwayListener, + IconButton, + ListSubheader, + MenuList, + Paper, + Popper, +} from '@mui/material' + +export type BNAvatarMenuProps = Pick & { + children?: ReactNode +} + +export function BNAvatarMenu(props: BNAvatarMenuProps) { + const { children, ...avatarProps } = props + const [menuAnchorEl, setMenuAnchorEl] = useState(null) + + const openMenu = (event: MouseEvent) => { + setMenuAnchorEl(event.currentTarget) + } + + const closeMenu = () => { + setMenuAnchorEl(null) + } + + const menuIsOpen = Boolean(menuAnchorEl) + + return ( + <> + + + + theme.zIndex.appBar + 1 }} + modifiers={[{ name: 'offset', options: { offset: [0, 8] } }]} + > + + +
{children}
+
+
+
+ + ) +} + +BNAvatarMenu.MenuList = (props: { children: ReactNode }) => { + return {props.children} +} + +BNAvatarMenu.SubHeader = (props: { children: ReactNode }) => { + return ( + + {props.children} + + ) +} diff --git a/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx new file mode 100644 index 0000000..3c951b3 --- /dev/null +++ b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx @@ -0,0 +1,69 @@ +import { type ReactNode, createContext, useContext, useRef, useState } from 'react' + +import { type AvatarProps, ClickAwayListener, Paper, Popper } from '@mui/material' + +import { HamburgerMenuIcon } from './HamburgerMenuIcon' + +const HamburgerMenuContext = createContext<{ + closeMenu: () => void +} | null>(null) + +export function useHamburgerMenu() { + const context = useContext(HamburgerMenuContext) + if (!context) { + throw new Error('useHamburgerMenu must be used within BNHamburgerMenu') + } + return context +} + +export type BNHamburgerMenuProps = Pick & { + children?: ReactNode +} + +export function BNHamburgerMenu(props: BNHamburgerMenuProps) { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + const toggleMenu = () => { + setIsOpen((prev) => !prev) + } + + const closeMenu = () => { + setIsOpen(false) + } + + return ( + + +
+ +
+
+ + {isOpen && buttonRef.current && ( + theme.zIndex.appBar + 1 }} + modifiers={[{ name: 'offset', options: { offset: [0, 18] } }]} + > + +
{props.children}
+
+
+ )} +
+ + + ) +} diff --git a/src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx b/src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx new file mode 100644 index 0000000..f2b8708 --- /dev/null +++ b/src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx @@ -0,0 +1,77 @@ +import { keyframes, styled } from '@mui/material' + +// Define Keyframes +const bottombarOpen = keyframes` + 0% { top: 19px; } + 50% { top: 12px; transform: rotate(0deg); } + 100% { top: 12px; transform: rotate(-45deg); } +` + +const bottombarClose = keyframes` + 0% { top: 12px; transform: rotate(-45deg); } + 50% { top: 12px; transform: rotate(0deg); } + 100% { top: 19px;} +` + +const topbarOpen = keyframes` + 0% { top: 9px; } + 50% { top: 12px; transform: rotate(0deg); } + 100% { top: 12px; transform: rotate(45deg); } +` + +const topbarClose = keyframes` + 0% { top: 12px; transform: rotate(45deg); } + 50% { top: 12px; transform: rotate(0deg); } + 100% { top: 9px; } +` + +export const HamburgerMenuIcon = styled('button')({ + border: 'none', + margin: 0, + padding: 0, + overflow: 'visible', + background: 'transparent', + color: 'inherit', + font: 'inherit', + lineHeight: 'normal', + appearance: 'none', + outline: 'none', + cursor: 'pointer', + position: 'relative', + width: '28px', + height: '28px', + display: 'inline-block', + verticalAlign: 'middle', + borderRadius: '50%', + top: 0, + '&:focus': { + outline: '2px solid currentColor', + outlineOffset: '2px', + }, + '&:focus:not(:focus-visible)': { + outline: 'none', + }, + '& div': { + display: 'block', + position: 'absolute', + height: 2, + width: '100%', + background: 'currentColor', + opacity: 1, + left: 0, + transformOrigin: 'center center', + willChange: 'transform, top', + }, + '&[data-open="true"] div:nth-of-type(1)': { + animation: `${topbarOpen} 0.65s ease forwards`, + }, + '&[data-open="false"] div:nth-of-type(1)': { + animation: `${topbarClose} 0.65s ease forwards`, + }, + '&[data-open="true"] div:nth-of-type(2)': { + animation: `${bottombarOpen} 0.65s ease forwards`, + }, + '&[data-open="false"] div:nth-of-type(2)': { + animation: `${bottombarClose} 0.65s ease forwards`, + }, +}) diff --git a/src/components/app-header/header-tabs.tsx b/src/components/app-header/header-tabs.tsx new file mode 100644 index 0000000..fa1e12f --- /dev/null +++ b/src/components/app-header/header-tabs.tsx @@ -0,0 +1,72 @@ +import { type ReactNode, useState } from 'react' + +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' +import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp' +import { + ClickAwayListener, + Tab as MuiTab, + Tabs as MuiTabs, + Paper, + Popper, + type TabProps, + type TabsProps, +} from '@mui/material' + +export const Tabs = (props: TabsProps) => { + return ( + + {props.children} + + ) +} + +export function Tab(props: TabProps) { + return +} + +export const TabWithSubMenu = ({ + label, + children, +}: { + label: string + children: ReactNode +}) => { + const [tabMenuAnchorEl, setTabMenuAnchorEl] = useState(null) + + const handleTabClick = (event: React.MouseEvent) => { + setTabMenuAnchorEl(event.currentTarget) + } + + const handleTabClose = () => { + setTabMenuAnchorEl(null) + } + + const isOpen = Boolean(tabMenuAnchorEl) + + return ( + <> + : } + iconPosition="end" + tabIndex={0} + onClick={handleTabClick} + sx={{ opacity: 1, paddingRight: 0 }} + /> + + + +
{children}
+
+
+
+ + ) +} diff --git a/src/components/app-header/menu/BNListItem.tsx b/src/components/app-header/menu/BNListItem.tsx new file mode 100644 index 0000000..061d2c9 --- /dev/null +++ b/src/components/app-header/menu/BNListItem.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react' + +import { + ListItem, + ListItemButton, + type ListItemButtonProps, + ListItemIcon, + type ListItemProps, + ListItemText, +} from '@mui/material' + +import { useHamburgerMenu } from '../hamburger-menu/BNHamburgerMenu' + +export type BNListItemProps = { + label: string + hideMenuOnClick?: boolean + icon?: ReactNode +} & ListItemButtonProps + +export function BNListItem( + props: { label: string; hideMenuOnClick?: boolean } & ListItemButtonProps< + C, + { component?: C } + > +) { + const { hideMenuOnClick = true, children, label, ...otherProps } = props + const { closeMenu } = useHamburgerMenu() + + // Allow time for click ripple animation so the user sees feedback their click was registered + const closeMenuWithDelay = () => { + if (hideMenuOnClick) { + setTimeout(() => { + closeMenu() + }, 300) + } + } + + return ( + + + {props.icon && {props.icon}} + + + {children} + + ) +} diff --git a/src/components/app-header/menu/BNListItemWithChildren.tsx b/src/components/app-header/menu/BNListItemWithChildren.tsx new file mode 100644 index 0000000..36bc74f --- /dev/null +++ b/src/components/app-header/menu/BNListItemWithChildren.tsx @@ -0,0 +1,36 @@ +import { type ReactNode, useState } from 'react' + +import ExpandLessIcon from '@mui/icons-material/ExpandLessOutlined' +import ExpandMoreIcon from '@mui/icons-material/ExpandMoreOutlined' +import { Box, Collapse, ListItemButton, ListItemText } from '@mui/material' + +export function BNListItemWithChildren({ + label, + children, +}: { + label: string + children: ReactNode +}) { + const [isOpen, setIsOpen] = useState(false) + + const toggleDrawer = () => { + setIsOpen((prev) => !prev) + } + + return ( + <> + + + {isOpen ? : } + + + {children} + + + ) +} diff --git a/src/stories/BNAppBar.stories.tsx b/src/stories/BNAppBar.stories.tsx index c1af82d..1d4ca83 100644 --- a/src/stories/BNAppBar.stories.tsx +++ b/src/stories/BNAppBar.stories.tsx @@ -5,6 +5,7 @@ import { Notifications } from '@mui/icons-material' import LogoutIconOutlined from '@mui/icons-material/LogoutOutlined' import { IconButton } from '@mui/material' import { Badge } from '@mui/material' +import { useColorScheme } from '@mui/material/styles' import { BNAppSwitcher } from '../components/BNAppSwitcher' import { BNLogo } from '../components/BNLogo' @@ -288,3 +289,81 @@ export const WithLinkComponent: Story = { }, }, } + +export const MenuSubheader: Story = { + parameters: { + layout: 'fullscreen', + }, + args: { + title: 'App Title', + color: 'primary', + selectedTabValue: 'home', + avatar: exampleAvatar, + LinkComponent: MockLinkComponent, + menuSubheaderLabel: 'User Menu', + tabs: exampleTabs, + menuItems: Array.from({ length: 5 }) + .map((_, i) => ({ + label: `Menu Item ${i + 1}`, + onClick: () => {}, + dense: true, + })) + .concat([ + { divider: true } as any, + { label: 'Sign Out', icon: , onClick: () => {} }, + ]), + }, +} + +export const ThemeToggle: Story = { + parameters: { + layout: 'fullscreen', + }, + args: { + title: 'App Title', + color: 'primary', + selectedTabValue: 'home', + avatar: exampleAvatar, + LinkComponent: MockLinkComponent, + tabs: exampleTabs, + includeThemeToggle: true, + menuItems: [ + { label: 'Profile', to: '/profile' }, + { label: 'Settings', to: '/settings' }, + { divider: true } as any, + { label: 'Logout', onClick: () => {}, icon: }, + ], + }, +} + +export const TabDropdownMenu: Story = { + parameters: { + layout: 'fullscreen', + }, + args: { + title: 'App Title', + color: 'primary', + selectedTabValue: 'home', + avatar: exampleAvatar, + LinkComponent: MockLinkComponent, + tabs: [ + ...exampleTabs, + { + label: 'Jobs', + value: 'jobs', + children: [ + { label: 'Proxy', to: '/proxy' }, + { label: 'Post Sale', to: '/post-sale' }, + { label: 'Regulatory', to: '/regulatory' }, + { label: 'Corporate Actions', to: '/corporate-actions' }, + { label: 'Bankruptcy', to: '/bankruptcy' }, + ], + }, + ], + menuItems: [ + { label: 'Account', to: '/account' }, + { divider: true } as any, + { label: 'Logout', onClick: () => {}, icon: }, + ], + }, +} diff --git a/src/stories/CHANGELOG.mdx b/src/stories/CHANGELOG.mdx index 6549078..f87d9de 100644 --- a/src/stories/CHANGELOG.mdx +++ b/src/stories/CHANGELOG.mdx @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2025-10-13 + +### Added + +- Theme mode toggle in `BNAnimatedMenuIcon` using `ToggleButtonGroup` and `useColorScheme`; `BNAppBar` supports `includeThemeToggle` to auto-insert the toggle before Logout (bnappbar-theme-toggle, 2025-10-13) +- Tabs dropdown menus: `BNAppBar` tabs accept `children` to render a desktop dropdown; mobile drawer shows child links under a non-clickable parent label (tabs-dropdown, 2025-10-13) + +### Changed + +- Normalize tab alignment/height and icon spacing via `MuiAppBar` theme overrides to align dropdown tab with others (tabs-alignment, 2025-10-13) +- Reposition account `Popper` closer to avatar and adjust offset when App Switcher is present (popper-offset, 2025-10-13) +- Refine `MuiAccordion` outlined variants, borders, and radius across expanded/collapsed states; add column summary layout variant and hover/spacing tweaks (accordion-styles, 2025-10-13) + +### Fixed + +- Remove blank menu items and unnecessary dividers in tab dropdown and mobile drawer; render divider-only items as real `Divider` elements and collapse consecutive dividers (menu-divider-fix, 2025-10-13) + +### Documentation + +- Add stories for theme toggle in menu, tab dropdown menu, long menu with subheader, and app switcher offset scenarios (stories-added, 2025-10-13) + +### Build + +- Migrate from deprecated `vitest.workspace.js` to `projects` in `vite.config.js` using `@storybook/addon-vitest` (vite-projects-migration, 2025-10-13) + ## [1.0.0] - 2025-09-24 ### Added diff --git a/src/themes/base/components/MuiAccordion.ts b/src/themes/base/components/MuiAccordion.ts index ba78466..c294bcc 100644 --- a/src/themes/base/components/MuiAccordion.ts +++ b/src/themes/base/components/MuiAccordion.ts @@ -16,6 +16,19 @@ const components: ThemeOptions['components'] = { '& .MuiList-root': { padding: 0, }, + '&:first-of-type': { + '& .MuiButtonBase-root': { + borderTopLeftRadius: 4, + borderTopRightRadius: 4, + }, + }, + '&:last-of-type': { + borderBlockEnd: `1px solid ${theme.vars.palette.divider}`, + '& .MuiButtonBase-root': { + borderBottomLeftRadius: 4, + borderBottomRightRadius: 4, + }, + }, variants: [ { props: { variant: 'outlined', disableGutters: true }, diff --git a/src/themes/base/components/MuiAppBar.ts b/src/themes/base/components/MuiAppBar.ts index f6e1834..822f2f7 100644 --- a/src/themes/base/components/MuiAppBar.ts +++ b/src/themes/base/components/MuiAppBar.ts @@ -17,6 +17,28 @@ const components: ThemeOptions['components'] = { '& .MuiTabs-flexContainer': { height: theme.layout?.navbarHeight, }, + // Normalize tab layout so tabs with icons (labelIcon) don't get taller/misaligned + '& .MuiTab-root': { + lineHeight: 1, + textTransform: 'none', + alignItems: 'center', + flexDirection: 'row', + minHeight: 48, + paddingTop: 0, + paddingBottom: 0, + '& .MuiTab-iconWrapper': { + marginBottom: 0, + marginLeft: theme.spacing(0.5), + }, + '&.MuiTab-labelIcon': { + minHeight: 48, + paddingTop: 0, + paddingBottom: 0, + '& .MuiTab-iconWrapper': { + marginBottom: 0, + }, + }, + }, '&.MuiAppBar-root.MuiAppBar-colorPrimary': { backgroundColor: theme.vars.palette.appBarPrimary?.defaultFill, color: theme.vars.palette.appBarPrimary?.defaultContrast, diff --git a/vite.config.js b/vite.config.js index b474a2e..84a9ae0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,4 +1,6 @@ +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' import reactSwc from '@vitejs/plugin-react-swc' +import path from 'node:path' import { defineConfig } from 'vite' export default defineConfig({ @@ -11,5 +13,23 @@ export default defineConfig({ optimizeDeps: { include: ['@mui/material', '@mui/icons-material'], }, - // This empty config is sufficient for Storybook to work + test: { + // Default project (node/jsdom/etc.) can go here if needed + }, + // Define additional Vitest projects replacing vitest.workspace.js + projects: [ + { + plugins: [storybookTest({ configDir: path.join(process.cwd(), '.storybook') })], + test: { + name: 'storybook', + browser: { + enabled: true, + headless: true, + provider: 'playwright', + instances: [{ browser: 'chromium' }], + }, + setupFiles: ['.storybook/vitest.setup.ts'], + }, + }, + ], })