From 2b386069f6f48d1bb45ad81478c132aed56c4638 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 11:11:42 -0500 Subject: [PATCH 01/36] fix: MuiAccordion styling for first and last items --- src/themes/base/components/MuiAccordion.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 }, From 3b3795a5ff4e3744f81bd89128cd61ce54c53d18 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 11:11:52 -0500 Subject: [PATCH 02/36] feat: Enhance Vite configuration for Storybook testing - Added Storybook test configuration using Vitest with Playwright support. - Defined a new project for Storybook tests, enabling browser testing with specified settings. - Included setup files for Vitest integration with Storybook. --- vite.config.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/vite.config.js b/vite.config.js index b474a2e..2a09494 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,7 @@ import reactSwc from '@vitejs/plugin-react-swc' import { defineConfig } from 'vite' +import path from 'node:path' +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' export default defineConfig({ plugins: [reactSwc()], @@ -11,5 +13,25 @@ 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'], + }, + }, + ], }) From f3e7da255aef8a50717bf0ff587301d778740a14 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 11:13:31 -0500 Subject: [PATCH 03/36] feat: Add theme toggle functionality and enhance menu components - Introduced a theme toggle feature in the BNAnimatedMenuIcon, allowing users to switch between light, dark, and system modes. - Enhanced BNAppBar to conditionally include the theme toggle in the menu items. - Updated BNAppBarDrawer to sanitize menu items - Improved tab dropdown functionality in BNAppBar - Added new props and slot customization options --- src/components/app-bar/BNAnimatedMenuIcon.tsx | 321 +++++++++++++----- src/components/app-bar/BNAppBar.tsx | 238 +++++++++++-- src/components/app-bar/BNAppBarDrawer.tsx | 69 ++-- src/stories/BNAppBar.stories.tsx | 110 ++++++ src/themes/base/components/MuiAppBar.ts | 22 ++ 5 files changed, 631 insertions(+), 129 deletions(-) diff --git a/src/components/app-bar/BNAnimatedMenuIcon.tsx b/src/components/app-bar/BNAnimatedMenuIcon.tsx index 56010ce..c7192ee 100644 --- a/src/components/app-bar/BNAnimatedMenuIcon.tsx +++ b/src/components/app-bar/BNAnimatedMenuIcon.tsx @@ -2,19 +2,29 @@ import { type MouseEvent, type ReactNode, 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', { @@ -117,6 +149,7 @@ const StyledMenuIcon = styled('button', { ) export const BNAnimatedMenuIcon = ({ + open, subheaderLabel, menuItems, onDrawerToggle, @@ -124,10 +157,17 @@ 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 = + typeof (theme as any)?.layout?.appSwitcher?.top === 'number' + ? (theme as any).layout.appSwitcher.top + : 0 const handleMenuClick = (event: MouseEvent) => { if ((isMobile || useAnimatedIconOnly) && onDrawerToggle) { @@ -150,6 +190,42 @@ export const BNAnimatedMenuIcon = ({ backgroundImage: 'var(--Paper-overlay)', lineHeight: '2.5rem', }) + + const ThemeToggleItem = () => { + const { mode, setMode } = useColorScheme() + const handleChange = ( + _event: React.MouseEvent, + nextMode: 'light' | 'dark' | 'system' | null + ) => { + if (nextMode) setMode(nextMode) + } + return ( + + + + + Light + + + + System + + + + Dark + + + + ) + } + // Use animated icon for mobile OR when useAnimatedIconOnly is true if (isMobile || useAnimatedIconOnly) { return ( @@ -165,30 +241,67 @@ 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 + const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + 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 +309,107 @@ 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) { + if (isMobile) return null + 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..2c5533d 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,7 +17,16 @@ 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' @@ -20,10 +36,10 @@ const getLogoImgStyles = (theme: any, src?: string): React.CSSProperties => ({ height: 44, ...(src && !src?.endsWith('.svg') && { - backgroundColor: theme.vars.palette.common.white, - padding: theme.spacing(0.5), - borderRadius: 4, - }), + backgroundColor: theme.vars.palette.common.white, + padding: theme.spacing(0.5), + borderRadius: 4, + }), }) type BNAppBarLink = { @@ -32,6 +48,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 +61,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 +97,7 @@ export function BNAppBar({ menuItems, avatar, menuSubheaderLabel, + includeThemeToggle, 'aria-label': ariaLabel, children, slots = {}, @@ -75,10 +107,62 @@ export function BNAppBar({ const theme = useTheme() const isDesktop = useMediaQuery(theme.breakpoints.up('md')) + // 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: any) => i.isThemeToggle)) { + items.push({ label: 'Theme', isThemeToggle: true } as any) + } + 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]) + + // Hide theme toggle in the mobile drawer + const drawerMenuItems = React.useMemo(() => { + if (!processedMenuItems) return processedMenuItems + if (isDesktop) return processedMenuItems + return processedMenuItems.filter((i: any) => i?.isThemeToggle !== true) + }, [processedMenuItems, isDesktop]) + return ( @@ -105,39 +189,139 @@ export function BNAppBar({ {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 } + divider?: boolean + isThemeToggle?: boolean } export interface BNAppBarDrawerProps { @@ -51,6 +53,28 @@ export const BNAppBarDrawer = ({ LinkComponent, hasAppSwitcher = false, }: BNAppBarDrawerProps) => { + const sanitizedMenuItems = React.useMemo(() => { + // Remove theme toggle items if any slipped through + const withoutToggles = (menuItems || []).filter((i: any) => i?.isThemeToggle !== true) + // Collapse consecutive dividers and trim leading/trailing dividers + const compact: BNAppBarDrawerMenuItem[] = [] + let lastWasDivider = false + for (const item of withoutToggles) { + const isDivider = (item as any)?.divider === true + if (isDivider) { + if (compact.length === 0 || lastWasDivider) continue + compact.push({ divider: true, label: '' } as any) + lastWasDivider = true + } else { + compact.push(item) + lastWasDivider = false + } + } + if (compact.length > 0 && (compact[compact.length - 1] as any)?.divider === true) { + compact.pop() + } + return compact + }, [menuItems]) return ( 0 && ( + {sanitizedMenuItems.length > 0 && ( - {menuItems.map((item, index) => ( - - { - item.onClick?.() - onMenuItemClick?.(item.label) - }} - role="button" - aria-label={item.label} - tabIndex={0} - {...item} - > - {item.icon && {item.icon}} - - - - ))} + {sanitizedMenuItems.map((item, index) => { + if ((item as any)?.divider === true) { + return + } + return ( + + { + item.onClick?.() + onMenuItemClick?.(item.label) + }} + role="button" + aria-label={item.label} + tabIndex={0} + {...item} + > + {item.icon && {item.icon}} + + + + ) + })} )} diff --git a/src/stories/BNAppBar.stories.tsx b/src/stories/BNAppBar.stories.tsx index c1af82d..758e08a 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,112 @@ export const WithLinkComponent: Story = { }, }, } + +export const WithMenuSubheaderAndLongMenu: 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: 10 }) + .map((_, i) => ({ + label: `Menu Item ${i + 1}`, + onClick: () => {}, + dense: true, + })) + .concat([ + { divider: true } as any, + { label: 'Sign Out', icon: , onClick: () => {} }, + ]), + }, +} + +export const WithAppSwitcherAndMenuOffset: Story = { + parameters: { + layout: 'fullscreen', + }, + args: { + color: 'secondary', + selectedTabValue: 'home', + avatar: exampleAvatar, + LinkComponent: MockLinkComponent, + tabs: exampleTabs, + children: ( + + ), + menuSubheaderLabel: 'User Menu', + menuItems: [ + { label: 'Profile', to: '/profile' }, + { label: 'Settings', to: '/settings' }, + { divider: true } as any, + { label: 'Sign Out', icon: , onClick: () => {} }, + ], + }, +} + +export const WithThemeToggleInMenu: 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 WithTabDropdownMenu: 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/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, From 128b296abf23417156c983d13016d855a6395f60 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 12:57:53 -0500 Subject: [PATCH 04/36] refactor: Simplify menu item handling and enhance theme toggle integration - Removed unnecessary state management for menu positioning in BNAnimatedMenuIcon. - Updated BNAppBar to directly use processed menu items, eliminating the mobile drawer filter. - Introduced a theme toggle component in BNAppBarDrawer, allowing users to switch themes. - Enhanced menu item rendering logic to support nested items and improved accessibility. - Adjusted styling and layout for better user experience in the app bar drawer. --- src/components/app-bar/BNAnimatedMenuIcon.tsx | 7 +- src/components/app-bar/BNAppBar.tsx | 9 +- src/components/app-bar/BNAppBarDrawer.tsx | 136 +++++++++++++++--- src/stories/BNAppBar.stories.tsx | 41 ++++-- 4 files changed, 147 insertions(+), 46 deletions(-) diff --git a/src/components/app-bar/BNAnimatedMenuIcon.tsx b/src/components/app-bar/BNAnimatedMenuIcon.tsx index c7192ee..1f57e1a 100644 --- a/src/components/app-bar/BNAnimatedMenuIcon.tsx +++ b/src/components/app-bar/BNAnimatedMenuIcon.tsx @@ -248,14 +248,10 @@ export const BNAnimatedMenuIcon = ({ const PaperComp = slots.paper ?? Paper const MenuListComp = slots.menuList ?? MenuList const MenuItemComp = slots.menuItem ?? MuiMenuItem - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) return ( } if ((item as any).isThemeToggle) { - if (isMobile) return null return } return ( diff --git a/src/components/app-bar/BNAppBar.tsx b/src/components/app-bar/BNAppBar.tsx index 2c5533d..8c87fec 100644 --- a/src/components/app-bar/BNAppBar.tsx +++ b/src/components/app-bar/BNAppBar.tsx @@ -156,13 +156,6 @@ export function BNAppBar({ return items }, [menuItems, includeThemeToggle]) - // Hide theme toggle in the mobile drawer - const drawerMenuItems = React.useMemo(() => { - if (!processedMenuItems) return processedMenuItems - if (isDesktop) return processedMenuItems - return processedMenuItems.filter((i: any) => i?.isThemeToggle !== true) - }, [processedMenuItems, isDesktop]) - return ( { + 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 = React.useMemo(() => { - // Remove theme toggle items if any slipped through - const withoutToggles = (menuItems || []).filter((i: any) => i?.isThemeToggle !== true) + // Keep theme toggle in drawer; just sanitize dividers + const withoutToggles = (menuItems || []) // Collapse consecutive dividers and trim leading/trailing dividers const compact: BNAppBarDrawerMenuItem[] = [] let lastWasDivider = false @@ -93,7 +134,7 @@ export const BNAppBarDrawer = ({ return { '& .MuiDrawer-paper': { - width: 280, + width: 320, top: totalTopOffset, height: `calc(100vh - ${totalTopOffset}px)`, }, @@ -116,27 +157,73 @@ export const BNAppBarDrawer = ({ {tabs.length > 0 && ( <> - {tabs.map((tab) => ( - - { - tab.onClick?.() - onTabClick?.(tab.value) - }} - role="button" - aria-label={`Navigate to ${tab.label}`} - tabIndex={0} - {...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}} + + + + ) + })} + + ) + })} - {menuItems.length > 0 && } + {sanitizedMenuItems.length > 0 && } )} @@ -147,6 +234,9 @@ export const BNAppBarDrawer = ({ if ((item as any)?.divider === true) { return } + if ((item as any)?.isThemeToggle === true) { + return + } return ( {}, + onClick: () => { }, icon: , }, ], @@ -147,7 +147,7 @@ export const WithLogoComponent: Story = { menuItems: [ { label: 'Logout', - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -178,7 +178,7 @@ export const WithLogoImg: Story = { menuItems: [ { label: 'Logout', - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -205,7 +205,7 @@ export const WithAppSwitcher: Story = { menuItems: [ { label: 'Logout', - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -305,12 +305,12 @@ export const WithMenuSubheaderAndLongMenu: Story = { menuItems: Array.from({ length: 10 }) .map((_, i) => ({ label: `Menu Item ${i + 1}`, - onClick: () => {}, + onClick: () => { }, dense: true, })) .concat([ { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => {} }, + { label: 'Sign Out', icon: , onClick: () => { } }, ]), }, } @@ -341,7 +341,7 @@ export const WithAppSwitcherAndMenuOffset: Story = { { label: 'Profile', to: '/profile' }, { label: 'Settings', to: '/settings' }, { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => {} }, + { label: 'Sign Out', icon: , onClick: () => { } }, ], }, } @@ -362,7 +362,30 @@ export const WithThemeToggleInMenu: Story = { { label: 'Profile', to: '/profile' }, { label: 'Settings', to: '/settings' }, { divider: true } as any, - { label: 'Logout', onClick: () => {}, icon: }, + { label: 'Logout', onClick: () => { }, icon: }, + ], + }, +} + +export const WithThemeToggleMobile: Story = { + parameters: { + layout: 'fullscreen', + viewport: { defaultViewport: 'mobile1' }, + }, + args: { + title: 'App Title', + color: 'primary', + selectedTabValue: 'home', + avatar: exampleAvatar, + LinkComponent: MockLinkComponent, + tabs: exampleTabs, + includeThemeToggle: true, + menuItems: [ + { label: 'Profile', to: '/profile' }, + { label: 'Preferences', to: '/settings/preferences' }, + { label: 'Help', to: '/help' }, + { divider: true } as any, + { label: 'Logout', onClick: () => { }, icon: }, ], }, } @@ -394,7 +417,7 @@ export const WithTabDropdownMenu: Story = { menuItems: [ { label: 'Account', to: '/account' }, { divider: true } as any, - { label: 'Logout', onClick: () => {}, icon: }, + { label: 'Logout', onClick: () => { }, icon: }, ], }, } From 16b688b40fbdbdae546446140aad1e0c21e5c77d Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 12:58:02 -0500 Subject: [PATCH 05/36] chore: Bump package version to 1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From 2de3f1cbb6522c9999d8a8883bb5117e624d401d Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 13:01:16 -0500 Subject: [PATCH 06/36] chore: Update package version to 1.1.0 in package-lock.json --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", From 59e0c4fbb5fe1fc1c2828299a95edbb2d7216721 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 13:01:45 -0500 Subject: [PATCH 07/36] chore: Update CHANGELOG for version 1.1.0 - Added theme mode toggle in BNAnimatedMenuIcon and support for auto-inserting it in BNAppBar. - Enhanced BNAppBar with dropdown menus for tabs and improved mobile drawer functionality. - Normalized tab alignment and spacing, repositioned account Popper, and refined MuiAccordion styles. - Fixed issues with blank menu items and dividers in dropdowns. - Added documentation stories for new features and migrated Vite configuration for Storybook testing. --- src/stories/CHANGELOG.mdx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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 From 92fca29ac1e937d74b38e93c989fdd48659e0f62 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Mon, 13 Oct 2025 16:22:32 -0500 Subject: [PATCH 08/36] refactor: Optimize theme toggle integration and menu item handling - Extracted ThemeToggleItem component in BNAnimatedMenuIcon to prevent unnecessary re-renders. - Refined menu item rendering logic for better accessibility and maintainability. --- src/components/app-bar/BNAnimatedMenuIcon.tsx | 82 ++++++++++--------- src/components/app-bar/BNAppBar.tsx | 22 ++++- src/components/app-bar/BNAppBarDrawer.tsx | 58 ++++++++----- 3 files changed, 101 insertions(+), 61 deletions(-) diff --git a/src/components/app-bar/BNAnimatedMenuIcon.tsx b/src/components/app-bar/BNAnimatedMenuIcon.tsx index 1f57e1a..96024c8 100644 --- a/src/components/app-bar/BNAnimatedMenuIcon.tsx +++ b/src/components/app-bar/BNAnimatedMenuIcon.tsx @@ -1,6 +1,6 @@ 'use client' -import { type MouseEvent, type ReactNode, useState } from 'react' +import { type MouseEvent, type ReactNode, useState, memo } from 'react' import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded' import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded' @@ -148,6 +148,42 @@ 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, @@ -164,10 +200,11 @@ export const BNAnimatedMenuIcon = ({ const [menuAnchorEl, setMenuAnchorEl] = useState(null) const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const appSwitcherTopOffset = - typeof (theme as any)?.layout?.appSwitcher?.top === 'number' - ? (theme as any).layout.appSwitcher.top - : 0 + 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) { @@ -191,40 +228,7 @@ export const BNAnimatedMenuIcon = ({ lineHeight: '2.5rem', }) - const ThemeToggleItem = () => { - const { mode, setMode } = useColorScheme() - const handleChange = ( - _event: React.MouseEvent, - nextMode: 'light' | 'dark' | 'system' | null - ) => { - if (nextMode) setMode(nextMode) - } - return ( - - - - - Light - - - - System - - - - Dark - - - - ) - } + // Use animated icon for mobile OR when useAnimatedIconOnly is true if (isMobile || useAnimatedIconOnly) { diff --git a/src/components/app-bar/BNAppBar.tsx b/src/components/app-bar/BNAppBar.tsx index 8c87fec..c65aee2 100644 --- a/src/components/app-bar/BNAppBar.tsx +++ b/src/components/app-bar/BNAppBar.tsx @@ -29,6 +29,7 @@ import type { 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', @@ -156,6 +157,25 @@ export function BNAppBar({ 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 ( void - to?: string | { pathname: string; search?: string; hash?: string; state?: any } - divider?: boolean - isThemeToggle?: boolean + 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 @@ -94,24 +111,23 @@ export const BNAppBarDrawer = ({ ) } - const sanitizedMenuItems = React.useMemo(() => { - // Keep theme toggle in drawer; just sanitize dividers - const withoutToggles = (menuItems || []) - // Collapse consecutive dividers and trim leading/trailing dividers + const sanitizedMenuItems: BNAppBarDrawerMenuItem[] = React.useMemo(() => { + const items = menuItems || [] const compact: BNAppBarDrawerMenuItem[] = [] let lastWasDivider = false - for (const item of withoutToggles) { - const isDivider = (item as any)?.divider === true + 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, label: '' } as any) + compact.push({ divider: true } as DrawerDividerItem) lastWasDivider = true } else { compact.push(item) lastWasDivider = false } } - if (compact.length > 0 && (compact[compact.length - 1] as any)?.divider === true) { + const last = compact[compact.length - 1] + if (last && 'divider' in last && last.divider === true) { compact.pop() } return compact @@ -231,28 +247,28 @@ export const BNAppBarDrawer = ({ {sanitizedMenuItems.length > 0 && ( {sanitizedMenuItems.map((item, index) => { - if ((item as any)?.divider === true) { + if ('divider' in item && item.divider === true) { return } - if ((item as any)?.isThemeToggle === true) { + if ('isThemeToggle' in item && item.isThemeToggle === true) { return } + const action = item as DrawerActionItem return ( { - item.onClick?.() - onMenuItemClick?.(item.label) + action.onClick?.() + onMenuItemClick?.(action.label) }} role="button" - aria-label={item.label} + aria-label={action.label} tabIndex={0} - {...item} > - {item.icon && {item.icon}} - + {action.icon && {action.icon}} + ) From 273a362a1c82b4fffeb1c0c12bf524fc209f51fe Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Tue, 14 Oct 2025 06:53:48 -0500 Subject: [PATCH 09/36] prettier --- .gitignore | 9 +++++- AGENTS.md | 23 ++++++++++++++ CLAUDE.md | 23 ++++++++++++++ src/components/app-bar/BNAnimatedMenuIcon.tsx | 4 +-- src/components/app-bar/BNAppBar.tsx | 16 +++++----- src/components/app-bar/BNAppBarDrawer.tsx | 30 ++++++++++++++----- src/stories/BNAppBar.stories.tsx | 20 ++++++------- vite.config.js | 8 ++--- 8 files changed, 98 insertions(+), 35 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md 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/src/components/app-bar/BNAnimatedMenuIcon.tsx b/src/components/app-bar/BNAnimatedMenuIcon.tsx index 96024c8..37ae2d4 100644 --- a/src/components/app-bar/BNAnimatedMenuIcon.tsx +++ b/src/components/app-bar/BNAnimatedMenuIcon.tsx @@ -1,6 +1,6 @@ 'use client' -import { type MouseEvent, type ReactNode, useState, memo } 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' @@ -228,8 +228,6 @@ export const BNAnimatedMenuIcon = ({ lineHeight: '2.5rem', }) - - // Use animated icon for mobile OR when useAnimatedIconOnly is true if (isMobile || useAnimatedIconOnly) { return ( diff --git a/src/components/app-bar/BNAppBar.tsx b/src/components/app-bar/BNAppBar.tsx index c65aee2..dd31ab9 100644 --- a/src/components/app-bar/BNAppBar.tsx +++ b/src/components/app-bar/BNAppBar.tsx @@ -37,10 +37,10 @@ const getLogoImgStyles = (theme: any, src?: string): React.CSSProperties => ({ height: 44, ...(src && !src?.endsWith('.svg') && { - backgroundColor: theme.vars.palette.common.white, - padding: theme.spacing(0.5), - borderRadius: 4, - }), + backgroundColor: theme.vars.palette.common.white, + padding: theme.spacing(0.5), + borderRadius: 4, + }), }) type BNAppBarLink = { @@ -189,10 +189,10 @@ export function BNAppBar({ ? React.createElement(slots.logoComponent, slotProps.logoComponent) : slots.logoImg ? React.createElement(slots.logoImg, { - alt: 'Logo', - style: getLogoImgStyles(theme, slotProps.logoImg?.src), - ...slotProps.logoImg, - }) + alt: 'Logo', + style: getLogoImgStyles(theme, slotProps.logoImg?.src), + ...slotProps.logoImg, + }) : null} {title && ( diff --git a/src/components/app-bar/BNAppBarDrawer.tsx b/src/components/app-bar/BNAppBarDrawer.tsx index b4e69b3..16b37cc 100644 --- a/src/components/app-bar/BNAppBarDrawer.tsx +++ b/src/components/app-bar/BNAppBarDrawer.tsx @@ -1,5 +1,8 @@ import React from 'react' +import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded' +import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded' +import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded' import { Box, Divider, @@ -9,14 +12,11 @@ import { ListItemButton, ListItemIcon, ListItemText, - Typography, ToggleButton, ToggleButtonGroup, + Typography, } from '@mui/material' import { useColorScheme } from '@mui/material/styles' -import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded' -import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded' -import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded' export interface BNAppBarDrawerTab { label: string @@ -27,7 +27,9 @@ export interface BNAppBarDrawerTab { to?: string | { pathname: string; search?: string; hash?: string; state?: any } } -type DrawerLink = string | { pathname: string; search?: string; hash?: string; state?: any } +type DrawerLink = + | string + | { pathname: string; search?: string; hash?: string; state?: any } type DrawerActionItem = { label: string @@ -204,15 +206,27 @@ export const BNAppBarDrawer = ({ + {tab.label} } /> {children.map((item: any, idx: number) => { - if (item?.divider === true && (!item.label || String(item.label).trim() === '')) { - return + if ( + item?.divider === true && + (!item.label || String(item.label).trim() === '') + ) { + return ( + + ) } return ( diff --git a/src/stories/BNAppBar.stories.tsx b/src/stories/BNAppBar.stories.tsx index b272861..0d0ddf6 100644 --- a/src/stories/BNAppBar.stories.tsx +++ b/src/stories/BNAppBar.stories.tsx @@ -73,7 +73,7 @@ export const Primary: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -147,7 +147,7 @@ export const WithLogoComponent: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -178,7 +178,7 @@ export const WithLogoImg: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -205,7 +205,7 @@ export const WithAppSwitcher: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -305,12 +305,12 @@ export const WithMenuSubheaderAndLongMenu: Story = { menuItems: Array.from({ length: 10 }) .map((_, i) => ({ label: `Menu Item ${i + 1}`, - onClick: () => { }, + onClick: () => {}, dense: true, })) .concat([ { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => { } }, + { label: 'Sign Out', icon: , onClick: () => {} }, ]), }, } @@ -341,7 +341,7 @@ export const WithAppSwitcherAndMenuOffset: Story = { { label: 'Profile', to: '/profile' }, { label: 'Settings', to: '/settings' }, { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => { } }, + { label: 'Sign Out', icon: , onClick: () => {} }, ], }, } @@ -362,7 +362,7 @@ export const WithThemeToggleInMenu: Story = { { label: 'Profile', to: '/profile' }, { label: 'Settings', to: '/settings' }, { divider: true } as any, - { label: 'Logout', onClick: () => { }, icon: }, + { label: 'Logout', onClick: () => {}, icon: }, ], }, } @@ -385,7 +385,7 @@ export const WithThemeToggleMobile: Story = { { label: 'Preferences', to: '/settings/preferences' }, { label: 'Help', to: '/help' }, { divider: true } as any, - { label: 'Logout', onClick: () => { }, icon: }, + { label: 'Logout', onClick: () => {}, icon: }, ], }, } @@ -417,7 +417,7 @@ export const WithTabDropdownMenu: Story = { menuItems: [ { label: 'Account', to: '/account' }, { divider: true } as any, - { label: 'Logout', onClick: () => { }, icon: }, + { label: 'Logout', onClick: () => {}, icon: }, ], }, } diff --git a/vite.config.js b/vite.config.js index 2a09494..84a9ae0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,7 @@ +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' import reactSwc from '@vitejs/plugin-react-swc' -import { defineConfig } from 'vite' import path from 'node:path' -import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' +import { defineConfig } from 'vite' export default defineConfig({ plugins: [reactSwc()], @@ -19,9 +19,7 @@ export default defineConfig({ // Define additional Vitest projects replacing vitest.workspace.js projects: [ { - plugins: [ - storybookTest({ configDir: path.join(process.cwd(), '.storybook') }), - ], + plugins: [storybookTest({ configDir: path.join(process.cwd(), '.storybook') })], test: { name: 'storybook', browser: { From 22cb7d80996f776f49dbee27cbfedf5f7f51bc80 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Tue, 14 Oct 2025 07:57:44 -0500 Subject: [PATCH 10/36] refactor: remove any type assertion --- src/components/app-bar/BNAppBar.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/app-bar/BNAppBar.tsx b/src/components/app-bar/BNAppBar.tsx index dd31ab9..a2f2899 100644 --- a/src/components/app-bar/BNAppBar.tsx +++ b/src/components/app-bar/BNAppBar.tsx @@ -37,10 +37,10 @@ const getLogoImgStyles = (theme: any, src?: string): React.CSSProperties => ({ height: 44, ...(src && !src?.endsWith('.svg') && { - backgroundColor: theme.vars.palette.common.white, - padding: theme.spacing(0.5), - borderRadius: 4, - }), + backgroundColor: theme.vars.palette.common.white, + padding: theme.spacing(0.5), + borderRadius: 4, + }), }) type BNAppBarLink = { @@ -131,8 +131,9 @@ export function BNAppBar({ if (!menuItems || menuItems.length === 0) return menuItems const items = [...menuItems] - if (includeThemeToggle && !items.some((i: any) => i.isThemeToggle)) { - items.push({ label: 'Theme', isThemeToggle: true } as any) + 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 @@ -189,10 +190,10 @@ export function BNAppBar({ ? React.createElement(slots.logoComponent, slotProps.logoComponent) : slots.logoImg ? React.createElement(slots.logoImg, { - alt: 'Logo', - style: getLogoImgStyles(theme, slotProps.logoImg?.src), - ...slotProps.logoImg, - }) + alt: 'Logo', + style: getLogoImgStyles(theme, slotProps.logoImg?.src), + ...slotProps.logoImg, + }) : null} {title && ( From 17db66c9edd6d03c931d74643c1966aeb6bc1edc Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Tue, 14 Oct 2025 08:00:32 -0500 Subject: [PATCH 11/36] accessibility fix --- src/components/app-bar/BNAppBar.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/app-bar/BNAppBar.tsx b/src/components/app-bar/BNAppBar.tsx index a2f2899..993ed86 100644 --- a/src/components/app-bar/BNAppBar.tsx +++ b/src/components/app-bar/BNAppBar.tsx @@ -107,6 +107,7 @@ 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) @@ -196,7 +197,7 @@ export function BNAppBar({ }) : null} {title && ( - + {title} )} @@ -212,7 +213,8 @@ export function BNAppBar({ {tabs.map((tab) => { From d7b2f15818fc9ca2b832ef10a7c2553dd05d2292 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Tue, 14 Oct 2025 08:05:10 -0500 Subject: [PATCH 12/36] remove redundant story --- src/stories/BNAppBar.stories.tsx | 45 ++++++++------------------------ 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/src/stories/BNAppBar.stories.tsx b/src/stories/BNAppBar.stories.tsx index 0d0ddf6..c37cff4 100644 --- a/src/stories/BNAppBar.stories.tsx +++ b/src/stories/BNAppBar.stories.tsx @@ -73,7 +73,7 @@ export const Primary: Story = { menuItems: [ { label: 'Logout', - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -147,7 +147,7 @@ export const WithLogoComponent: Story = { menuItems: [ { label: 'Logout', - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -178,7 +178,7 @@ export const WithLogoImg: Story = { menuItems: [ { label: 'Logout', - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -205,7 +205,7 @@ export const WithAppSwitcher: Story = { menuItems: [ { label: 'Logout', - onClick: () => {}, + onClick: () => { }, icon: , }, ], @@ -305,12 +305,12 @@ export const WithMenuSubheaderAndLongMenu: Story = { menuItems: Array.from({ length: 10 }) .map((_, i) => ({ label: `Menu Item ${i + 1}`, - onClick: () => {}, + onClick: () => { }, dense: true, })) .concat([ { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => {} }, + { label: 'Sign Out', icon: , onClick: () => { } }, ]), }, } @@ -341,12 +341,12 @@ export const WithAppSwitcherAndMenuOffset: Story = { { label: 'Profile', to: '/profile' }, { label: 'Settings', to: '/settings' }, { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => {} }, + { label: 'Sign Out', icon: , onClick: () => { } }, ], }, } -export const WithThemeToggleInMenu: Story = { +export const ThemeToggle: Story = { parameters: { layout: 'fullscreen', }, @@ -362,35 +362,12 @@ export const WithThemeToggleInMenu: Story = { { label: 'Profile', to: '/profile' }, { label: 'Settings', to: '/settings' }, { divider: true } as any, - { label: 'Logout', onClick: () => {}, icon: }, + { label: 'Logout', onClick: () => { }, icon: }, ], }, } -export const WithThemeToggleMobile: Story = { - parameters: { - layout: 'fullscreen', - viewport: { defaultViewport: 'mobile1' }, - }, - args: { - title: 'App Title', - color: 'primary', - selectedTabValue: 'home', - avatar: exampleAvatar, - LinkComponent: MockLinkComponent, - tabs: exampleTabs, - includeThemeToggle: true, - menuItems: [ - { label: 'Profile', to: '/profile' }, - { label: 'Preferences', to: '/settings/preferences' }, - { label: 'Help', to: '/help' }, - { divider: true } as any, - { label: 'Logout', onClick: () => {}, icon: }, - ], - }, -} - -export const WithTabDropdownMenu: Story = { +export const TabDropdownMenu: Story = { parameters: { layout: 'fullscreen', }, @@ -417,7 +394,7 @@ export const WithTabDropdownMenu: Story = { menuItems: [ { label: 'Account', to: '/account' }, { divider: true } as any, - { label: 'Logout', onClick: () => {}, icon: }, + { label: 'Logout', onClick: () => { }, icon: }, ], }, } From 5b4efa9f92de6ac1ad0a80bd4bc9fae5ffab5e83 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Tue, 14 Oct 2025 08:07:53 -0500 Subject: [PATCH 13/36] refactor: Rename story and reduce menu items count - Renamed the story from WithMenuSubheaderAndLongMenu to MenuSubheader for clarity. - Reduced the number of menu items from 10 to 5 to streamline the example. --- src/stories/BNAppBar.stories.tsx | 35 ++------------------------------ 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/src/stories/BNAppBar.stories.tsx b/src/stories/BNAppBar.stories.tsx index c37cff4..cf03461 100644 --- a/src/stories/BNAppBar.stories.tsx +++ b/src/stories/BNAppBar.stories.tsx @@ -290,7 +290,7 @@ export const WithLinkComponent: Story = { }, } -export const WithMenuSubheaderAndLongMenu: Story = { +export const MenuSubheader: Story = { parameters: { layout: 'fullscreen', }, @@ -302,7 +302,7 @@ export const WithMenuSubheaderAndLongMenu: Story = { LinkComponent: MockLinkComponent, menuSubheaderLabel: 'User Menu', tabs: exampleTabs, - menuItems: Array.from({ length: 10 }) + menuItems: Array.from({ length: 5 }) .map((_, i) => ({ label: `Menu Item ${i + 1}`, onClick: () => { }, @@ -315,37 +315,6 @@ export const WithMenuSubheaderAndLongMenu: Story = { }, } -export const WithAppSwitcherAndMenuOffset: Story = { - parameters: { - layout: 'fullscreen', - }, - args: { - color: 'secondary', - selectedTabValue: 'home', - avatar: exampleAvatar, - LinkComponent: MockLinkComponent, - tabs: exampleTabs, - children: ( - - ), - menuSubheaderLabel: 'User Menu', - menuItems: [ - { label: 'Profile', to: '/profile' }, - { label: 'Settings', to: '/settings' }, - { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => { } }, - ], - }, -} - export const ThemeToggle: Story = { parameters: { layout: 'fullscreen', From ba75ea81bda6b0c3dcc050c96f20a440da1bb891 Mon Sep 17 00:00:00 2001 From: Dallas Peters Date: Tue, 14 Oct 2025 08:55:06 -0500 Subject: [PATCH 14/36] prettier --- src/components/app-bar/BNAppBar.tsx | 20 +++++++++++--------- src/stories/BNAppBar.stories.tsx | 16 ++++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/components/app-bar/BNAppBar.tsx b/src/components/app-bar/BNAppBar.tsx index 993ed86..efc176c 100644 --- a/src/components/app-bar/BNAppBar.tsx +++ b/src/components/app-bar/BNAppBar.tsx @@ -37,10 +37,10 @@ const getLogoImgStyles = (theme: any, src?: string): React.CSSProperties => ({ height: 44, ...(src && !src?.endsWith('.svg') && { - backgroundColor: theme.vars.palette.common.white, - padding: theme.spacing(0.5), - borderRadius: 4, - }), + backgroundColor: theme.vars.palette.common.white, + padding: theme.spacing(0.5), + borderRadius: 4, + }), }) type BNAppBarLink = { @@ -191,10 +191,10 @@ export function BNAppBar({ ? React.createElement(slots.logoComponent, slotProps.logoComponent) : slots.logoImg ? React.createElement(slots.logoImg, { - alt: 'Logo', - style: getLogoImgStyles(theme, slotProps.logoImg?.src), - ...slotProps.logoImg, - }) + alt: 'Logo', + style: getLogoImgStyles(theme, slotProps.logoImg?.src), + ...slotProps.logoImg, + }) : null} {title && ( @@ -213,7 +213,9 @@ export function BNAppBar({ diff --git a/src/stories/BNAppBar.stories.tsx b/src/stories/BNAppBar.stories.tsx index cf03461..1d4ca83 100644 --- a/src/stories/BNAppBar.stories.tsx +++ b/src/stories/BNAppBar.stories.tsx @@ -73,7 +73,7 @@ export const Primary: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -147,7 +147,7 @@ export const WithLogoComponent: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -178,7 +178,7 @@ export const WithLogoImg: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -205,7 +205,7 @@ export const WithAppSwitcher: Story = { menuItems: [ { label: 'Logout', - onClick: () => { }, + onClick: () => {}, icon: , }, ], @@ -305,12 +305,12 @@ export const MenuSubheader: Story = { menuItems: Array.from({ length: 5 }) .map((_, i) => ({ label: `Menu Item ${i + 1}`, - onClick: () => { }, + onClick: () => {}, dense: true, })) .concat([ { divider: true } as any, - { label: 'Sign Out', icon: , onClick: () => { } }, + { label: 'Sign Out', icon: , onClick: () => {} }, ]), }, } @@ -331,7 +331,7 @@ export const ThemeToggle: Story = { { label: 'Profile', to: '/profile' }, { label: 'Settings', to: '/settings' }, { divider: true } as any, - { label: 'Logout', onClick: () => { }, icon: }, + { label: 'Logout', onClick: () => {}, icon: }, ], }, } @@ -363,7 +363,7 @@ export const TabDropdownMenu: Story = { menuItems: [ { label: 'Account', to: '/account' }, { divider: true } as any, - { label: 'Logout', onClick: () => { }, icon: }, + { label: 'Logout', onClick: () => {}, icon: }, ], }, } From b7e0b1b9dfb104166199ee49f28583a530c06380 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Fri, 7 Nov 2025 12:04:55 -0500 Subject: [PATCH 15/36] Initial version of BNAppHeader --- .../app-header/BNAppHeader.stories.tsx | 75 +++++++++++++ src/components/app-header/BNAppHeader.tsx | 106 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/components/app-header/BNAppHeader.stories.tsx create mode 100644 src/components/app-header/BNAppHeader.tsx diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx new file mode 100644 index 0000000..5441830 --- /dev/null +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { Start } from '@mui/icons-material' +import { Divider, Link, MenuItem, Tab, Tabs, Typography } from '@mui/material' + +import { BNLogo } from '../BNLogo' +import BNAppHeader from './BNAppHeader' + +const meta = { + title: 'Custom Components/BNAppHeader', + component: BNAppHeader, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + + My App + + + + + + + + + + ), +} + +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..8ef6f8f --- /dev/null +++ b/src/components/app-header/BNAppHeader.tsx @@ -0,0 +1,106 @@ +import { type ElementType, type ReactNode } from 'react' + +import { + AppBar, + Box, + type BoxProps, + Stack, + Tab, + type TabProps, + Tabs, + type TabsProps, + Toolbar, + type ToolbarProps, + Typography, + type TypographyProps, +} from '@mui/material' + +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.ControlBar = ({ 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} + + ) +} + +BNAppHeader.Tabs = (props: TabsProps) => { + return ( + + {props.children} + + ) +} + +BNAppHeader.Tab = function BNAppHeaderTab( + props: TabProps +) { + return +} + +export default BNAppHeader From a984a1fdc0f5aaa1405b59a537d9ff034ecab2f4 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Fri, 7 Nov 2025 16:06:23 -0500 Subject: [PATCH 16/36] Desktop tabs --- .../app-header/BNAppHeader.stories.tsx | 24 +++++-- src/components/app-header/BNAppHeader.tsx | 67 ++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 5441830..2e55102 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { Start } from '@mui/icons-material' -import { Divider, Link, MenuItem, Tab, Tabs, Typography } from '@mui/material' +import { Divider, Link, MenuItem, MenuList, Tab, Tabs, Typography } from '@mui/material' import { BNLogo } from '../BNLogo' import BNAppHeader from './BNAppHeader' @@ -26,12 +26,28 @@ export const Default: Story = { My App - + - + + + + Proxy + + + Bankruptcy + + + Reorg + + + + - + + + Mobile Menu + ), diff --git a/src/components/app-header/BNAppHeader.tsx b/src/components/app-header/BNAppHeader.tsx index 8ef6f8f..b21649c 100644 --- a/src/components/app-header/BNAppHeader.tsx +++ b/src/components/app-header/BNAppHeader.tsx @@ -1,9 +1,14 @@ -import { type ElementType, type ReactNode } from 'react' +import { type ElementType, type ReactNode, useState } from 'react' +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' +import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp' import { AppBar, Box, type BoxProps, + ClickAwayListener, + Paper, + Popper, Stack, Tab, type TabProps, @@ -13,6 +18,8 @@ import { type ToolbarProps, Typography, type TypographyProps, + useMediaQuery, + useTheme, } from '@mui/material' function BNAppHeader({ children }: { children?: React.ReactNode }) { @@ -70,6 +77,18 @@ BNAppHeader.Section = ({ children }: { children?: ReactNode }) => { ) } +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 = ({ children }: { children?: ReactNode }) => { return ( } +BNAppHeader.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} + /> + + + +
{children}
+
+
+
+ + ) +} + export default BNAppHeader From 7a2789018ce7d56ff86c7f815a63e2ebe9f7506f Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Mon, 10 Nov 2025 15:15:07 -0500 Subject: [PATCH 17/36] Move ThemeToggle into its own file --- src/components/app-header/ThemeToggle.tsx | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/components/app-header/ThemeToggle.tsx diff --git a/src/components/app-header/ThemeToggle.tsx b/src/components/app-header/ThemeToggle.tsx new file mode 100644 index 0000000..3f0900c --- /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 ThemeToggleItem() { + const { mode, setMode } = useColorScheme() + + const handleChange = ( + _event: React.MouseEvent, + nextMode: 'light' | 'dark' | 'system' | null + ) => { + if (nextMode) setMode(nextMode) + } + + return ( + + + + + Light + + + + System + + + + Dark + + + + ) +} From 6fb7d3950deae8854a1aad0f793da572d2fd38d3 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Tue, 11 Nov 2025 15:36:07 -0500 Subject: [PATCH 18/36] Add basic AvatarMenu --- src/components/app-header/AvatarMenu.tsx | 93 +++++++++++++++++++ .../app-header/BNAppHeader.stories.tsx | 57 +++++++++--- src/components/app-header/ThemeToggle.tsx | 2 +- 3 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 src/components/app-header/AvatarMenu.tsx diff --git a/src/components/app-header/AvatarMenu.tsx b/src/components/app-header/AvatarMenu.tsx new file mode 100644 index 0000000..bc27988 --- /dev/null +++ b/src/components/app-header/AvatarMenu.tsx @@ -0,0 +1,93 @@ +import { type MouseEvent, type ReactNode, useState } from 'react' + +import { + Avatar, + ClickAwayListener, + IconButton, + ListSubheader, + Paper, + Popper, + styled, +} from '@mui/material' + +export interface AvatarProps { + src: string + alt: string + children?: ReactNode +} + +function AvatarMenu(props: AvatarProps) { + const [menuAnchorEl, setMenuAnchorEl] = useState(null) + + const handleMenuClick = (event: MouseEvent) => { + setMenuAnchorEl(event.currentTarget) + } + + const handleMenuClose = () => { + setMenuAnchorEl(null) + } + + // TODO: Dummy implementation for now + const hasAppSwitcher = false + const appSwitcherTopOffset = 0 + const subheaderLabel = 'User Menu' + + return ( + <> + + + {props.children} + + + + + +
{props.children}
+
+
+
+ + ) +} + +AvatarMenu.SubHeader = (props: { children: ReactNode }) => { + return ( + + {props.children} + + ) +} + +export default AvatarMenu diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 2e55102..fad8295 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -1,10 +1,21 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { Start } from '@mui/icons-material' -import { Divider, Link, MenuItem, MenuList, Tab, Tabs, Typography } from '@mui/material' +import LogoutIconOutlined from '@mui/icons-material/LogoutOutlined' +import { + Divider, + ListItemIcon, + ListItemText, + ListSubheader, + MenuItem, + MenuList, + Typography, +} from '@mui/material' import { BNLogo } from '../BNLogo' +import AvatarMenu from './AvatarMenu' import BNAppHeader from './BNAppHeader' +import { ThemeToggle } from './ThemeToggle' const meta = { title: 'Custom Components/BNAppHeader', @@ -27,23 +38,47 @@ export const Default: Story = { My App - - - - + + + + + + + Proxy + + + Bankruptcy + + + Reorg + + + + + + + + Account + - Proxy + Profile - Bankruptcy + Settings + - Reorg + + + + Logout - - - + + Mobile Menu diff --git a/src/components/app-header/ThemeToggle.tsx b/src/components/app-header/ThemeToggle.tsx index 3f0900c..07320e6 100644 --- a/src/components/app-header/ThemeToggle.tsx +++ b/src/components/app-header/ThemeToggle.tsx @@ -3,7 +3,7 @@ import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded' import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded' import { Box, ToggleButton, ToggleButtonGroup, useColorScheme } from '@mui/material' -export function ThemeToggleItem() { +export function ThemeToggle() { const { mode, setMode } = useColorScheme() const handleChange = ( From 174bf0943a834d7b02a12edb43a363674fb0b858 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Wed, 12 Nov 2025 16:05:37 -0500 Subject: [PATCH 19/36] Initial version of DrawerMenu --- .../app-header/BNAppHeader.stories.tsx | 110 +++++++++- src/components/app-header/DrawerMenu.tsx | 194 ++++++++++++++++++ 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/components/app-header/DrawerMenu.tsx diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index fad8295..969ab92 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { Start } from '@mui/icons-material' import LogoutIconOutlined from '@mui/icons-material/LogoutOutlined' import { Divider, + List, + ListItem, + ListItemButton, ListItemIcon, ListItemText, ListSubheader, @@ -15,6 +17,7 @@ import { import { BNLogo } from '../BNLogo' import AvatarMenu from './AvatarMenu' import BNAppHeader from './BNAppHeader' +import DrawerMenu from './DrawerMenu' import { ThemeToggle } from './ThemeToggle' const meta = { @@ -81,7 +84,110 @@ export const Default: Story = { - Mobile Menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Account}> + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/app-header/DrawerMenu.tsx b/src/components/app-header/DrawerMenu.tsx new file mode 100644 index 0000000..26d5add --- /dev/null +++ b/src/components/app-header/DrawerMenu.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react' + +import ExpandLess from '@mui/icons-material/ExpandLessOutlined' +import ExpandMoreIcon from '@mui/icons-material/ExpandMoreOutlined' +import { + Box, + Collapse, + Drawer, + ListItem, + ListItemButton, + type ListItemButtonProps, + type ListItemProps, + ListItemText, +} from '@mui/material' +import { type CSSObject, keyframes, styled, useColorScheme } from '@mui/material/styles' + +// 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; } +` + +const StyledMenuIcon = styled('button', { + shouldForwardProp: (prop) => prop !== 'open', +})<{ open: boolean }>( + ({ open }): CSSObject => ({ + 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', + }, + '& div:nth-of-type(1)': { + animation: `${open ? topbarOpen : topbarClose} 0.65s ease forwards`, + }, + '& div:nth-of-type(2)': { + animation: `${open ? bottombarOpen : bottombarClose} 0.65s ease forwards`, + }, + }) +) + +export default function DrawerMenu({ + hasAppSwitcher = false, + children, +}: { + hasAppSwitcher?: boolean + children?: React.ReactNode +}) { + const [drawerOpen, setDrawerOpen] = useState(false) + + const hideDrawer = () => { + setDrawerOpen(false) + } + + const toggleDrawer = () => { + setDrawerOpen((prev) => !prev) + } + + return ( + <> + +
+
+ + { + const navbarHeight = theme.layout?.navbarHeight || 66 + const appSwitcherHeight = hasAppSwitcher + ? theme.layout?.appSwitcherHeight || 48 + : 0 + const totalTopOffset = navbarHeight + appSwitcherHeight + + return { + '& .MuiDrawer-paper': { + width: 320, + top: totalTopOffset, + height: `calc(100vh - ${totalTopOffset}px)`, + }, + '& .MuiBackdrop-root.MuiModal-backdrop': { + backgroundColor: 'rgba(0, 0, 0, 0.1)', + }, + } + }} + > + + {children} + + + + ) +} + +DrawerMenu.ListItem = (props: ListItemProps) => { + return ( + + {props.children} + + ) +} + +DrawerMenu.ListItemWithChildren = ({ + label, + children, +}: { + label: string + children: React.ReactNode +}) => { + const [isOpen, setIsOpen] = useState(false) + + const toggleDrawer = () => { + setIsOpen((prev) => !prev) + } + + return ( + <> + + + {isOpen ? : } + + + {children} + + + ) +} From aeb56d79cd0a6dfc62a21dd2f88460c08d113271 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Wed, 12 Nov 2025 16:17:48 -0500 Subject: [PATCH 20/36] Start writing documentation --- src/components/app-header/BNAppHeader.mdx | 11 +++++++++++ src/components/app-header/BNAppHeader.stories.tsx | 3 +++ 2 files changed, 14 insertions(+) create mode 100644 src/components/app-header/BNAppHeader.mdx diff --git a/src/components/app-header/BNAppHeader.mdx b/src/components/app-header/BNAppHeader.mdx new file mode 100644 index 0000000..ba24e9a --- /dev/null +++ b/src/components/app-header/BNAppHeader.mdx @@ -0,0 +1,11 @@ +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 index 969ab92..5194879 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -35,6 +35,9 @@ type Story = StoryObj export const Default: Story = { render: () => ( + + Client Name + From be107bc0146ea36e96328aac336deeb526bd8835 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Wed, 12 Nov 2025 16:18:11 -0500 Subject: [PATCH 21/36] Remove unneeded import --- src/components/app-header/BNAppHeader.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 5194879..21d1d59 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -4,7 +4,6 @@ import LogoutIconOutlined from '@mui/icons-material/LogoutOutlined' import { Divider, List, - ListItem, ListItemButton, ListItemIcon, ListItemText, From 0d95355868ccbf59b92828042d1adecb973da081 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Wed, 12 Nov 2025 17:51:32 -0500 Subject: [PATCH 22/36] Start porting the app switcher --- src/components/app-header/AppSwitcher.tsx | 61 +++++++++++++++++++ .../app-header/BNAppHeader.stories.tsx | 8 ++- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/components/app-header/AppSwitcher.tsx diff --git a/src/components/app-header/AppSwitcher.tsx b/src/components/app-header/AppSwitcher.tsx new file mode 100644 index 0000000..56e60fa --- /dev/null +++ b/src/components/app-header/AppSwitcher.tsx @@ -0,0 +1,61 @@ +import { type ReactNode, useState } from 'react' + +import AppsIcon from '@mui/icons-material/Apps' +import { Button, Menu } from '@mui/material' + +export default function AppSwitcher({ + currentAppTitle, + children, +}: { + currentAppTitle: 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: 'common.white', + }, + })} + > + {children} + + + ) +} diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 21d1d59..1332660 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -14,6 +14,7 @@ import { } from '@mui/material' import { BNLogo } from '../BNLogo' +import AppSwitcher from './AppSwitcher' import AvatarMenu from './AvatarMenu' import BNAppHeader from './BNAppHeader' import DrawerMenu from './DrawerMenu' @@ -36,6 +37,11 @@ export const Default: Story = { Client Name + + MIC Ops + Client Communications + Wealth Manager + @@ -86,7 +92,7 @@ export const Default: Story = { - + Date: Fri, 14 Nov 2025 10:47:06 -0500 Subject: [PATCH 23/36] Move header tabs into their own file --- src/components/app-header/BNAppHeader.tsx | 73 ++--------------------- src/components/app-header/header-tabs.tsx | 72 ++++++++++++++++++++++ 2 files changed, 78 insertions(+), 67 deletions(-) create mode 100644 src/components/app-header/header-tabs.tsx diff --git a/src/components/app-header/BNAppHeader.tsx b/src/components/app-header/BNAppHeader.tsx index b21649c..d829707 100644 --- a/src/components/app-header/BNAppHeader.tsx +++ b/src/components/app-header/BNAppHeader.tsx @@ -1,18 +1,11 @@ -import { type ElementType, type ReactNode, useState } from 'react' +import { type ReactNode, useState } from 'react' -import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' -import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp' import { AppBar, Box, type BoxProps, - ClickAwayListener, - Paper, - Popper, Stack, - Tab, type TabProps, - Tabs, type TabsProps, Toolbar, type ToolbarProps, @@ -22,6 +15,8 @@ import { useTheme, } from '@mui/material' +import { Tab, TabWithSubMenu, Tabs } from './header-tabs' + function BNAppHeader({ children }: { children?: React.ReactNode }) { return ( @@ -108,64 +103,8 @@ BNAppHeader.ControlBar = ({ children }: { children?: ReactNode }) => { ) } -BNAppHeader.Tabs = (props: TabsProps) => { - return ( - - {props.children} - - ) -} - -BNAppHeader.Tab = function BNAppHeaderTab( - props: TabProps -) { - return -} - -BNAppHeader.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} - /> - - - -
{children}
-
-
-
- - ) -} +BNAppHeader.Tabs = Tabs +BNAppHeader.Tab = Tab +BNAppHeader.TabWithSubMenu = TabWithSubMenu export default BNAppHeader diff --git a/src/components/app-header/header-tabs.tsx b/src/components/app-header/header-tabs.tsx new file mode 100644 index 0000000..127b483 --- /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 const Tab = function BNAppHeaderTab( + 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} + /> + + + +
{children}
+
+
+
+ + ) +} From edc6c6e4a9535840e65a1d37beee400bda4cd7a2 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Fri, 14 Nov 2025 11:01:15 -0500 Subject: [PATCH 24/36] Clean up AvatarMenu --- .../app-header/BNAppHeader.stories.tsx | 8 ++-- .../{AvatarMenu.tsx => BNAvatarMenu.tsx} | 39 ++++--------------- 2 files changed, 12 insertions(+), 35 deletions(-) rename src/components/app-header/{AvatarMenu.tsx => BNAvatarMenu.tsx} (63%) diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 1332660..0d61c0d 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -15,8 +15,8 @@ import { import { BNLogo } from '../BNLogo' import AppSwitcher from './AppSwitcher' -import AvatarMenu from './AvatarMenu' import BNAppHeader from './BNAppHeader' +import { BNAvatarMenu } from './BNAvatarMenu' import DrawerMenu from './DrawerMenu' import { ThemeToggle } from './ThemeToggle' @@ -67,12 +67,12 @@ export const Default: Story = { - - Account + Account Profile @@ -88,7 +88,7 @@ export const Default: Story = { Logout - +
diff --git a/src/components/app-header/AvatarMenu.tsx b/src/components/app-header/BNAvatarMenu.tsx similarity index 63% rename from src/components/app-header/AvatarMenu.tsx rename to src/components/app-header/BNAvatarMenu.tsx index bc27988..8c60f64 100644 --- a/src/components/app-header/AvatarMenu.tsx +++ b/src/components/app-header/BNAvatarMenu.tsx @@ -7,16 +7,15 @@ import { ListSubheader, Paper, Popper, - styled, } from '@mui/material' -export interface AvatarProps { +export interface AvatarMenuProps { src: string alt: string - children?: ReactNode + children: ReactNode } -function AvatarMenu(props: AvatarProps) { +export function BNAvatarMenu({ src, alt, children }: AvatarMenuProps) { const [menuAnchorEl, setMenuAnchorEl] = useState(null) const handleMenuClick = (event: MouseEvent) => { @@ -27,11 +26,6 @@ function AvatarMenu(props: AvatarProps) { setMenuAnchorEl(null) } - // TODO: Dummy implementation for now - const hasAppSwitcher = false - const appSwitcherTopOffset = 0 - const subheaderLabel = 'User Menu' - return ( <> - {props.children} - + /> -
{props.children}
+
{children}
@@ -82,12 +61,10 @@ function AvatarMenu(props: AvatarProps) { ) } -AvatarMenu.SubHeader = (props: { children: ReactNode }) => { +BNAvatarMenu.SubHeader = (props: { children: ReactNode }) => { return ( {props.children} ) } - -export default AvatarMenu From f8bfe8703b8e2054741fff3cc22a631260159193 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Fri, 14 Nov 2025 12:09:32 -0500 Subject: [PATCH 25/36] Refactor AppSwitcher --- .../app-header/BNAppHeader.stories.tsx | 12 ++++---- src/components/app-header/BNAppHeader.tsx | 23 ++------------- .../{AppSwitcher.tsx => BNAppSwitcher.tsx} | 29 +++++++++++++++---- src/components/app-header/ControlBar.tsx | 22 ++++++++++++++ 4 files changed, 54 insertions(+), 32 deletions(-) rename src/components/app-header/{AppSwitcher.tsx => BNAppSwitcher.tsx} (65%) create mode 100644 src/components/app-header/ControlBar.tsx diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 0d61c0d..9b9dfac 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -14,8 +14,8 @@ import { } from '@mui/material' import { BNLogo } from '../BNLogo' -import AppSwitcher from './AppSwitcher' import BNAppHeader from './BNAppHeader' +import { BNAppSwitcher } from './BNAppSwitcher' import { BNAvatarMenu } from './BNAvatarMenu' import DrawerMenu from './DrawerMenu' import { ThemeToggle } from './ThemeToggle' @@ -37,11 +37,11 @@ export const Default: Story = { Client Name - - MIC Ops - Client Communications - Wealth Manager - + + + + + diff --git a/src/components/app-header/BNAppHeader.tsx b/src/components/app-header/BNAppHeader.tsx index d829707..eed2f2c 100644 --- a/src/components/app-header/BNAppHeader.tsx +++ b/src/components/app-header/BNAppHeader.tsx @@ -15,6 +15,7 @@ import { useTheme, } from '@mui/material' +import { ControlBar } from './ControlBar' import { Tab, TabWithSubMenu, Tabs } from './header-tabs' function BNAppHeader({ children }: { children?: React.ReactNode }) { @@ -84,27 +85,9 @@ BNAppHeader.MobileOnlySection = ({ children }: { children?: ReactNode }) => { return isMobile ? children : null } -BNAppHeader.ControlBar = ({ 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} - - ) -} - -BNAppHeader.Tabs = Tabs +BNAppHeader.ControlBar = ControlBar BNAppHeader.Tab = Tab +BNAppHeader.Tabs = Tabs BNAppHeader.TabWithSubMenu = TabWithSubMenu export default BNAppHeader diff --git a/src/components/app-header/AppSwitcher.tsx b/src/components/app-header/BNAppSwitcher.tsx similarity index 65% rename from src/components/app-header/AppSwitcher.tsx rename to src/components/app-header/BNAppSwitcher.tsx index 56e60fa..9fb8575 100644 --- a/src/components/app-header/AppSwitcher.tsx +++ b/src/components/app-header/BNAppSwitcher.tsx @@ -1,13 +1,13 @@ import { type ReactNode, useState } from 'react' import AppsIcon from '@mui/icons-material/Apps' -import { Button, Menu } from '@mui/material' +import { Button, Menu, MenuItem, type MenuItemProps, styled } from '@mui/material' -export default function AppSwitcher({ - currentAppTitle, +export function BNAppSwitcher({ + currentAppName, children, }: { - currentAppTitle: string + currentAppName: string children: ReactNode }) { const [anchorEl, setAnchorEl] = useState(null) @@ -29,7 +29,7 @@ export default function AppSwitcher({ onClick={handleClick} endIcon={} > - {currentAppTitle} + {currentAppName} @@ -59,3 +59,20 @@ export default function AppSwitcher({ ) } + +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/ControlBar.tsx b/src/components/app-header/ControlBar.tsx new file mode 100644 index 0000000..c3c8ac1 --- /dev/null +++ b/src/components/app-header/ControlBar.tsx @@ -0,0 +1,22 @@ +import { type ReactNode } from 'react' + +import { Box } from '@mui/material' + +export function ControlBar({ 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} + + ) +} From d7860f86f45ee7d66dd1dfff52353ebd7df94733 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Fri, 14 Nov 2025 16:43:08 -0500 Subject: [PATCH 26/36] Rename ControlBar to BNControlBar --- src/components/app-header/BNAppHeader.tsx | 4 ++-- .../app-header/{ControlBar.tsx => BNControlBar.tsx} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/components/app-header/{ControlBar.tsx => BNControlBar.tsx} (87%) diff --git a/src/components/app-header/BNAppHeader.tsx b/src/components/app-header/BNAppHeader.tsx index eed2f2c..1f4c771 100644 --- a/src/components/app-header/BNAppHeader.tsx +++ b/src/components/app-header/BNAppHeader.tsx @@ -15,7 +15,7 @@ import { useTheme, } from '@mui/material' -import { ControlBar } from './ControlBar' +import { BNControlBar } from './BNControlBar' import { Tab, TabWithSubMenu, Tabs } from './header-tabs' function BNAppHeader({ children }: { children?: React.ReactNode }) { @@ -85,7 +85,7 @@ BNAppHeader.MobileOnlySection = ({ children }: { children?: ReactNode }) => { return isMobile ? children : null } -BNAppHeader.ControlBar = ControlBar +BNAppHeader.ControlBar = BNControlBar BNAppHeader.Tab = Tab BNAppHeader.Tabs = Tabs BNAppHeader.TabWithSubMenu = TabWithSubMenu diff --git a/src/components/app-header/ControlBar.tsx b/src/components/app-header/BNControlBar.tsx similarity index 87% rename from src/components/app-header/ControlBar.tsx rename to src/components/app-header/BNControlBar.tsx index c3c8ac1..a0d4eaf 100644 --- a/src/components/app-header/ControlBar.tsx +++ b/src/components/app-header/BNControlBar.tsx @@ -2,7 +2,7 @@ import { type ReactNode } from 'react' import { Box } from '@mui/material' -export function ControlBar({ children }: { children?: ReactNode }) { +export function BNControlBar({ children }: { children?: ReactNode }) { return ( ({ From 10275f00a5e01189f1bb0068a2ad926911540d13 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Thu, 20 Nov 2025 10:52:23 -0500 Subject: [PATCH 27/36] Simplify types --- src/components/app-header/BNAppSwitcher.tsx | 4 ++-- src/components/app-header/header-tabs.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/app-header/BNAppSwitcher.tsx b/src/components/app-header/BNAppSwitcher.tsx index 9fb8575..3eccc13 100644 --- a/src/components/app-header/BNAppSwitcher.tsx +++ b/src/components/app-header/BNAppSwitcher.tsx @@ -67,8 +67,8 @@ const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ }, })) -BNAppSwitcher.Item = ( - props: { name: string } & MenuItemProps +BNAppSwitcher.Item = ( + props: { name: string } & MenuItemProps ) => { return ( diff --git a/src/components/app-header/header-tabs.tsx b/src/components/app-header/header-tabs.tsx index 127b483..fa1e12f 100644 --- a/src/components/app-header/header-tabs.tsx +++ b/src/components/app-header/header-tabs.tsx @@ -19,9 +19,8 @@ export const Tabs = (props: TabsProps) => { ) } -export const Tab = function BNAppHeaderTab( - props: TabProps -) { + +export function Tab(props: TabProps) { return } @@ -46,7 +45,7 @@ export const TabWithSubMenu = ({ return ( <> - Date: Mon, 24 Nov 2025 14:41:12 -0500 Subject: [PATCH 28/36] Refactor AvatarMenu --- .../app-header/BNAppHeader.stories.tsx | 8 +- .../avatar-menu/BNAvatarMenu.stories.tsx | 75 +++++++++++++++++++ .../avatar-menu}/BNAvatarMenu.tsx | 40 +++++----- 3 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 src/components/layout/avatar-menu/BNAvatarMenu.stories.tsx rename src/components/{app-header => layout/avatar-menu}/BNAvatarMenu.tsx (54%) diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 9b9dfac..708e3eb 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -14,9 +14,9 @@ import { } from '@mui/material' import { BNLogo } from '../BNLogo' +import { BNAvatarMenu } from '../layout/avatar-menu/BNAvatarMenu' import BNAppHeader from './BNAppHeader' import { BNAppSwitcher } from './BNAppSwitcher' -import { BNAvatarMenu } from './BNAvatarMenu' import DrawerMenu from './DrawerMenu' import { ThemeToggle } from './ThemeToggle' @@ -72,8 +72,8 @@ export const Default: Story = { alt="User Avatar" > - Account - + + Account Profile @@ -87,7 +87,7 @@ export const Default: Story = { Logout - + diff --git a/src/components/layout/avatar-menu/BNAvatarMenu.stories.tsx b/src/components/layout/avatar-menu/BNAvatarMenu.stories.tsx new file mode 100644 index 0000000..948c48d --- /dev/null +++ b/src/components/layout/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 '../../app-header/ThemeToggle' +import { BNAvatarMenu } from './BNAvatarMenu' + +const meta = { + title: 'Custom Components/Layout/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/BNAvatarMenu.tsx b/src/components/layout/avatar-menu/BNAvatarMenu.tsx similarity index 54% rename from src/components/app-header/BNAvatarMenu.tsx rename to src/components/layout/avatar-menu/BNAvatarMenu.tsx index 8c60f64..a322788 100644 --- a/src/components/app-header/BNAvatarMenu.tsx +++ b/src/components/layout/avatar-menu/BNAvatarMenu.tsx @@ -2,57 +2,59 @@ import { type MouseEvent, type ReactNode, useState } from 'react' import { Avatar, + type AvatarProps, ClickAwayListener, IconButton, ListSubheader, + MenuList, Paper, Popper, } from '@mui/material' -export interface AvatarMenuProps { - src: string - alt: string - children: ReactNode +export type BNAvatarMenuProps = Pick & { + children?: ReactNode } -export function BNAvatarMenu({ src, alt, children }: AvatarMenuProps) { +export function BNAvatarMenu(props: BNAvatarMenuProps) { + const { children, ...avatarProps } = props const [menuAnchorEl, setMenuAnchorEl] = useState(null) - const handleMenuClick = (event: MouseEvent) => { + const openMenu = (event: MouseEvent) => { setMenuAnchorEl(event.currentTarget) } - const handleMenuClose = () => { + const closeMenu = () => { setMenuAnchorEl(null) } + const menuIsOpen = Boolean(menuAnchorEl) + return ( <> - - + +
{children}
@@ -61,6 +63,10 @@ export function BNAvatarMenu({ src, alt, children }: AvatarMenuProps) { ) } +BNAvatarMenu.MenuList = (props: { children: ReactNode }) => { + return {props.children} +} + BNAvatarMenu.SubHeader = (props: { children: ReactNode }) => { return ( From 446c3ee34a8186ef26acfad1c838e71aad29f786 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Mon, 24 Nov 2025 15:38:20 -0500 Subject: [PATCH 29/36] Fix popper offset --- src/components/layout/avatar-menu/BNAvatarMenu.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/layout/avatar-menu/BNAvatarMenu.tsx b/src/components/layout/avatar-menu/BNAvatarMenu.tsx index a322788..69b4a60 100644 --- a/src/components/layout/avatar-menu/BNAvatarMenu.tsx +++ b/src/components/layout/avatar-menu/BNAvatarMenu.tsx @@ -52,6 +52,7 @@ export function BNAvatarMenu(props: BNAvatarMenuProps) { open={menuIsOpen} anchorEl={menuAnchorEl} placement="bottom-end" + modifiers={[{ name: 'offset', options: { offset: [0, 8] } }]} > From 998f2d27d1673abde081a22b6f9f07f161ccef40 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Mon, 24 Nov 2025 16:20:16 -0500 Subject: [PATCH 30/36] Fix z-height --- src/components/layout/avatar-menu/BNAvatarMenu.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/layout/avatar-menu/BNAvatarMenu.tsx b/src/components/layout/avatar-menu/BNAvatarMenu.tsx index 69b4a60..d7ce1cc 100644 --- a/src/components/layout/avatar-menu/BNAvatarMenu.tsx +++ b/src/components/layout/avatar-menu/BNAvatarMenu.tsx @@ -52,6 +52,7 @@ export function BNAvatarMenu(props: BNAvatarMenuProps) { open={menuIsOpen} anchorEl={menuAnchorEl} placement="bottom-end" + sx={{ zIndex: (theme) => theme.zIndex.appBar + 1 }} modifiers={[{ name: 'offset', options: { offset: [0, 8] } }]} > From 6b8683436b82df65fab8d6a86f0104d621c949a6 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Tue, 25 Nov 2025 11:18:49 -0500 Subject: [PATCH 31/36] Add BNHamburgerMenu --- src/components/app-header/BNHamburgerMenu.tsx | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/components/app-header/BNHamburgerMenu.tsx diff --git a/src/components/app-header/BNHamburgerMenu.tsx b/src/components/app-header/BNHamburgerMenu.tsx new file mode 100644 index 0000000..3f2b952 --- /dev/null +++ b/src/components/app-header/BNHamburgerMenu.tsx @@ -0,0 +1,144 @@ +import { type MouseEvent, type ReactNode, useRef, useState } from 'react' + +import { + Avatar, + type AvatarProps, + type CSSObject, + ClickAwayListener, + Icon, + IconButton, + ListSubheader, + MenuList, + Paper, + Popper, + 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; } +` + +const StyledMenuIcon = styled('button')((): CSSObject => { + return { + 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`, + }, + } +}) + +export type BNHamburgerMenuProps = Pick & { + children?: ReactNode +} + +export function BNHamburgerMenu(props: BNHamburgerMenuProps) { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + const toggleMenu = (event: MouseEvent) => { + setIsOpen((prev) => !prev) + } + + const closeMenu = () => { + setIsOpen(false) + } + + return ( + +
+ +
+
+ + {isOpen && buttonRef.current && ( + theme.zIndex.appBar + 1 }} + modifiers={[{ name: 'offset', options: { offset: [0, 18] } }]} + > + +
{props.children}
+
+
+ )} +
+ + ) +} From c4e1d1254f989969f824d12a42997a13a22617e4 Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Tue, 25 Nov 2025 12:39:42 -0500 Subject: [PATCH 32/36] Hide menu on click --- src/components/app-header/BNHamburgerMenu.tsx | 110 ++++++++++++------ 1 file changed, 75 insertions(+), 35 deletions(-) diff --git a/src/components/app-header/BNHamburgerMenu.tsx b/src/components/app-header/BNHamburgerMenu.tsx index 3f2b952..e85177b 100644 --- a/src/components/app-header/BNHamburgerMenu.tsx +++ b/src/components/app-header/BNHamburgerMenu.tsx @@ -1,14 +1,17 @@ -import { type MouseEvent, type ReactNode, useRef, useState } from 'react' +import { + type MouseEvent, + type ReactNode, + createContext, + useContext, + useRef, + useState, +} from 'react' import { - Avatar, type AvatarProps, type CSSObject, ClickAwayListener, - Icon, - IconButton, - ListSubheader, - MenuList, + ListItem, Paper, Popper, keyframes, @@ -93,6 +96,18 @@ const StyledMenuIcon = styled('button')((): CSSObject => { } }) +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 } @@ -110,35 +125,60 @@ export function BNHamburgerMenu(props: BNHamburgerMenuProps) { } return ( - -
- -
-
- - {isOpen && buttonRef.current && ( - theme.zIndex.appBar + 1 }} - modifiers={[{ name: 'offset', options: { offset: [0, 18] } }]} + + +
+ - -
{props.children}
-
- - )} -
-
+
+
+ + {isOpen && buttonRef.current && ( + theme.zIndex.appBar + 1 }} + modifiers={[{ name: 'offset', options: { offset: [0, 18] } }]} + > + +
{props.children}
+
+
+ )} +
+ + + ) +} + +BNHamburgerMenu.ListItem = (props: { + hideMenuOnClick?: boolean + children: ReactNode +}) => { + const { hideMenuOnClick = true, children, ...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 ( + + {children} + ) } From 75a8a7f514c5983db1e3d700ad149ba8882f2d5e Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Tue, 25 Nov 2025 13:41:44 -0500 Subject: [PATCH 33/36] Reorganize files --- src/components/app-header/BNAppHeader.mdx | 3 +- .../app-header/BNAppHeader.stories.tsx | 2 +- .../avatar-menu/BNAvatarMenu.stories.tsx | 2 +- .../avatar-menu/BNAvatarMenu.tsx | 0 .../{ => hamburger-menu}/BNHamburgerMenu.tsx | 85 +------------------ .../hamburger-menu/HamburgerMenuIcon.tsx | 77 +++++++++++++++++ 6 files changed, 84 insertions(+), 85 deletions(-) rename src/components/{layout => app-header}/avatar-menu/BNAvatarMenu.stories.tsx (97%) rename src/components/{layout => app-header}/avatar-menu/BNAvatarMenu.tsx (100%) rename src/components/app-header/{ => hamburger-menu}/BNHamburgerMenu.tsx (55%) create mode 100644 src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx diff --git a/src/components/app-header/BNAppHeader.mdx b/src/components/app-header/BNAppHeader.mdx index ba24e9a..a2cab56 100644 --- a/src/components/app-header/BNAppHeader.mdx +++ b/src/components/app-header/BNAppHeader.mdx @@ -5,7 +5,8 @@ import meta, * as BNAppHeaderStories from './BNAppHeader.stories' # BNAppHeader -The `BNAppHeader` is a light wrapper around Material UI's `AppBar` component, with composible helper components + +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 index 708e3eb..86d12bc 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -14,11 +14,11 @@ import { } from '@mui/material' import { BNLogo } from '../BNLogo' -import { BNAvatarMenu } from '../layout/avatar-menu/BNAvatarMenu' import BNAppHeader from './BNAppHeader' import { BNAppSwitcher } from './BNAppSwitcher' import DrawerMenu from './DrawerMenu' import { ThemeToggle } from './ThemeToggle' +import { BNAvatarMenu } from './avatar-menu/BNAvatarMenu' const meta = { title: 'Custom Components/BNAppHeader', diff --git a/src/components/layout/avatar-menu/BNAvatarMenu.stories.tsx b/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx similarity index 97% rename from src/components/layout/avatar-menu/BNAvatarMenu.stories.tsx rename to src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx index 948c48d..62ff263 100644 --- a/src/components/layout/avatar-menu/BNAvatarMenu.stories.tsx +++ b/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx @@ -4,7 +4,7 @@ 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 '../../app-header/ThemeToggle' +import { ThemeToggle } from '../ThemeToggle' import { BNAvatarMenu } from './BNAvatarMenu' const meta = { diff --git a/src/components/layout/avatar-menu/BNAvatarMenu.tsx b/src/components/app-header/avatar-menu/BNAvatarMenu.tsx similarity index 100% rename from src/components/layout/avatar-menu/BNAvatarMenu.tsx rename to src/components/app-header/avatar-menu/BNAvatarMenu.tsx diff --git a/src/components/app-header/BNHamburgerMenu.tsx b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx similarity index 55% rename from src/components/app-header/BNHamburgerMenu.tsx rename to src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx index e85177b..7645ef8 100644 --- a/src/components/app-header/BNHamburgerMenu.tsx +++ b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx @@ -9,92 +9,13 @@ import { import { type AvatarProps, - type CSSObject, ClickAwayListener, ListItem, Paper, Popper, - 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; } -` - -const StyledMenuIcon = styled('button')((): CSSObject => { - return { - 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`, - }, - } -}) +import { HamburgerMenuIcon } from './HamburgerMenuIcon' const HamburgerMenuContext = createContext<{ closeMenu: () => void @@ -128,7 +49,7 @@ export function BNHamburgerMenu(props: BNHamburgerMenuProps) {
-
- + {isOpen && buttonRef.current && ( Date: Tue, 25 Nov 2025 13:47:39 -0500 Subject: [PATCH 34/36] Organize stories and remove unused props --- src/components/app-header/BNAppHeader.stories.tsx | 2 +- src/components/app-header/BNAppHeader.tsx | 3 +-- src/components/app-header/DrawerMenu.tsx | 3 +-- src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx | 2 +- src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 86d12bc..9aec13e 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -21,7 +21,7 @@ import { ThemeToggle } from './ThemeToggle' import { BNAvatarMenu } from './avatar-menu/BNAvatarMenu' const meta = { - title: 'Custom Components/BNAppHeader', + title: 'Custom Components/App Header/BNAppHeader', component: BNAppHeader, parameters: { layout: 'fullscreen', diff --git a/src/components/app-header/BNAppHeader.tsx b/src/components/app-header/BNAppHeader.tsx index 1f4c771..3d92043 100644 --- a/src/components/app-header/BNAppHeader.tsx +++ b/src/components/app-header/BNAppHeader.tsx @@ -1,11 +1,10 @@ -import { type ReactNode, useState } from 'react' +import { type ReactNode } from 'react' import { AppBar, Box, type BoxProps, Stack, - type TabProps, type TabsProps, Toolbar, type ToolbarProps, diff --git a/src/components/app-header/DrawerMenu.tsx b/src/components/app-header/DrawerMenu.tsx index 26d5add..50c8258 100644 --- a/src/components/app-header/DrawerMenu.tsx +++ b/src/components/app-header/DrawerMenu.tsx @@ -8,11 +8,10 @@ import { Drawer, ListItem, ListItemButton, - type ListItemButtonProps, type ListItemProps, ListItemText, } from '@mui/material' -import { type CSSObject, keyframes, styled, useColorScheme } from '@mui/material/styles' +import { type CSSObject, keyframes, styled } from '@mui/material/styles' // Define Keyframes const bottombarOpen = keyframes` diff --git a/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx b/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx index 62ff263..4d4fe69 100644 --- a/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx +++ b/src/components/app-header/avatar-menu/BNAvatarMenu.stories.tsx @@ -8,7 +8,7 @@ import { ThemeToggle } from '../ThemeToggle' import { BNAvatarMenu } from './BNAvatarMenu' const meta = { - title: 'Custom Components/Layout/BNAvatarMenu', + title: 'Custom Components/App Header/BNAvatarMenu', component: BNAvatarMenu, parameters: { layout: 'centered', diff --git a/src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx b/src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx index fe27388..f2b8708 100644 --- a/src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx +++ b/src/components/app-header/hamburger-menu/HamburgerMenuIcon.tsx @@ -1,4 +1,4 @@ -import { type CSSObject, keyframes, styled } from '@mui/material' +import { keyframes, styled } from '@mui/material' // Define Keyframes const bottombarOpen = keyframes` From 62e1469a7de6c0358879dfc13308772a8d9c3e5a Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Tue, 25 Nov 2025 13:49:24 -0500 Subject: [PATCH 35/36] Remove used param --- .../app-header/hamburger-menu/BNHamburgerMenu.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx index 7645ef8..d21336b 100644 --- a/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx +++ b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx @@ -1,11 +1,4 @@ -import { - type MouseEvent, - type ReactNode, - createContext, - useContext, - useRef, - useState, -} from 'react' +import { type ReactNode, createContext, useContext, useRef, useState } from 'react' import { type AvatarProps, @@ -37,7 +30,7 @@ export function BNHamburgerMenu(props: BNHamburgerMenuProps) { const [isOpen, setIsOpen] = useState(false) const buttonRef = useRef(null) - const toggleMenu = (event: MouseEvent) => { + const toggleMenu = () => { setIsOpen((prev) => !prev) } From f9800cc9d1dba1db22216088148c094ec3731fbb Mon Sep 17 00:00:00 2001 From: Ben Eiwnechter Date: Tue, 25 Nov 2025 15:16:46 -0500 Subject: [PATCH 36/36] Add BNListItem and BNListItemWithChildren --- .../app-header/BNAppHeader.stories.tsx | 116 ++--------- src/components/app-header/DrawerMenu.tsx | 193 ------------------ .../hamburger-menu/BNHamburgerMenu.tsx | 31 +-- src/components/app-header/menu/BNListItem.tsx | 52 +++++ .../menu/BNListItemWithChildren.tsx | 36 ++++ 5 files changed, 109 insertions(+), 319 deletions(-) delete mode 100644 src/components/app-header/DrawerMenu.tsx create mode 100644 src/components/app-header/menu/BNListItem.tsx create mode 100644 src/components/app-header/menu/BNListItemWithChildren.tsx diff --git a/src/components/app-header/BNAppHeader.stories.tsx b/src/components/app-header/BNAppHeader.stories.tsx index 9aec13e..05b885f 100644 --- a/src/components/app-header/BNAppHeader.stories.tsx +++ b/src/components/app-header/BNAppHeader.stories.tsx @@ -16,9 +16,11 @@ import { import { BNLogo } from '../BNLogo' import BNAppHeader from './BNAppHeader' import { BNAppSwitcher } from './BNAppSwitcher' -import DrawerMenu from './DrawerMenu' 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', @@ -92,110 +94,32 @@ export const Default: Story = { - + - - - - - - + + - - - - - - - - - - - - - - - + + + - - - - - - + + Account}> - - - - - - - - - - - - - - - - - - + + + } + /> - + diff --git a/src/components/app-header/DrawerMenu.tsx b/src/components/app-header/DrawerMenu.tsx deleted file mode 100644 index 50c8258..0000000 --- a/src/components/app-header/DrawerMenu.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { useState } from 'react' - -import ExpandLess from '@mui/icons-material/ExpandLessOutlined' -import ExpandMoreIcon from '@mui/icons-material/ExpandMoreOutlined' -import { - Box, - Collapse, - Drawer, - ListItem, - ListItemButton, - type ListItemProps, - ListItemText, -} from '@mui/material' -import { type CSSObject, keyframes, styled } from '@mui/material/styles' - -// 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; } -` - -const StyledMenuIcon = styled('button', { - shouldForwardProp: (prop) => prop !== 'open', -})<{ open: boolean }>( - ({ open }): CSSObject => ({ - 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', - }, - '& div:nth-of-type(1)': { - animation: `${open ? topbarOpen : topbarClose} 0.65s ease forwards`, - }, - '& div:nth-of-type(2)': { - animation: `${open ? bottombarOpen : bottombarClose} 0.65s ease forwards`, - }, - }) -) - -export default function DrawerMenu({ - hasAppSwitcher = false, - children, -}: { - hasAppSwitcher?: boolean - children?: React.ReactNode -}) { - const [drawerOpen, setDrawerOpen] = useState(false) - - const hideDrawer = () => { - setDrawerOpen(false) - } - - const toggleDrawer = () => { - setDrawerOpen((prev) => !prev) - } - - return ( - <> - -
-
- - { - const navbarHeight = theme.layout?.navbarHeight || 66 - const appSwitcherHeight = hasAppSwitcher - ? theme.layout?.appSwitcherHeight || 48 - : 0 - const totalTopOffset = navbarHeight + appSwitcherHeight - - return { - '& .MuiDrawer-paper': { - width: 320, - top: totalTopOffset, - height: `calc(100vh - ${totalTopOffset}px)`, - }, - '& .MuiBackdrop-root.MuiModal-backdrop': { - backgroundColor: 'rgba(0, 0, 0, 0.1)', - }, - } - }} - > - - {children} - - - - ) -} - -DrawerMenu.ListItem = (props: ListItemProps) => { - return ( - - {props.children} - - ) -} - -DrawerMenu.ListItemWithChildren = ({ - label, - children, -}: { - label: string - children: React.ReactNode -}) => { - const [isOpen, setIsOpen] = useState(false) - - const toggleDrawer = () => { - setIsOpen((prev) => !prev) - } - - return ( - <> - - - {isOpen ? : } - - - {children} - - - ) -} diff --git a/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx index d21336b..3c951b3 100644 --- a/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx +++ b/src/components/app-header/hamburger-menu/BNHamburgerMenu.tsx @@ -1,12 +1,6 @@ import { type ReactNode, createContext, useContext, useRef, useState } from 'react' -import { - type AvatarProps, - ClickAwayListener, - ListItem, - Paper, - Popper, -} from '@mui/material' +import { type AvatarProps, ClickAwayListener, Paper, Popper } from '@mui/material' import { HamburgerMenuIcon } from './HamburgerMenuIcon' @@ -73,26 +67,3 @@ export function BNHamburgerMenu(props: BNHamburgerMenuProps) { ) } - -BNHamburgerMenu.ListItem = (props: { - hideMenuOnClick?: boolean - children: ReactNode -}) => { - const { hideMenuOnClick = true, children, ...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 ( - - {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} + + + ) +}