diff --git a/package.json b/package.json index 6eba177..db72508 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pixel-base", - "version": "1.9.4", + "version": "1.9.5", "description": "Common UI components for web.pixelone.app, Based on React Bootstrap", "author": "muffrank", "license": "MIT", diff --git a/src/lib/dc-collapsible-menu/dc-collapsible-menu.stories.tsx b/src/lib/dc-collapsible-menu/dc-collapsible-menu.stories.tsx new file mode 100644 index 0000000..c0eab92 --- /dev/null +++ b/src/lib/dc-collapsible-menu/dc-collapsible-menu.stories.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { ComponentMeta, ComponentStory } from '@storybook/react' +import { useState } from "react" +import { CollapsibleMenu, type MenuItemProps } from "./dc-collapsible-menu" +import { Home, Settings, Users, FileText, ShoppingCart, BarChart, HelpCircle } from "lucide-react" + +export default { + title: 'DC CollapsibleMenu', + component: CollapsibleMenu +} as ComponentMeta + +const Template: ComponentStory = (args) => { + const [activeItem, setActiveItem] = useState("dashboard") + + const menuItems: MenuItemProps[] = [ + { + id: "dashboard", + label: "Dashboard", + icon: , + }, + { + id: "users", + label: "Users", + icon: , + items: [ + { + id: "user-management", + label: "User Management", + }, + { + id: "permissions", + label: "Permissions", + }, + { + id: "roles", + label: "Roles", + items: [ + { + id: "admin-roles", + label: "Admin Roles", + }, + { + id: "user-roles", + label: "User Roles", + }, + ], + }, + ], + }, + { + id: "reports", + label: "Reports", + icon: , + items: [ + { + id: "sales-report", + label: "Sales Report", + }, + { + id: "analytics", + label: "Analytics", + }, + ], + }, + { + id: "orders", + label: "Orders", + icon: , + content:
You have 5 pending orders
, + }, + { + id: "documents", + label: "Documents", + icon: , + disabled: true, + }, + { + id: "settings", + label: "Settings", + icon: , + }, + { + id: "help", + label: "Help & Support", + icon: , + }, + ] + const handleItemClick = (item: MenuItemProps) => { + setActiveItem(item.id) + console.log(`Clicked on: ${item.label}`) + } + + return ( +
+

Menu Example

+ +
+ ) +} + +export const Default = Template.bind({}) +Default.args = { + gap: '10px', + padding: '15px', + margin: '5px' +} diff --git a/src/lib/dc-collapsible-menu/dc-collapsible-menu.tsx b/src/lib/dc-collapsible-menu/dc-collapsible-menu.tsx new file mode 100644 index 0000000..5f3a2ac --- /dev/null +++ b/src/lib/dc-collapsible-menu/dc-collapsible-menu.tsx @@ -0,0 +1,269 @@ +import React from "react" +import { useState } from "react" +import { ChevronDown, ChevronRight } from "lucide-react" +import styled from "styled-components" + +export interface MenuItemProps { + id: string | number + label: string + content?: React.ReactNode + items?: MenuItemProps[] + icon?: React.ReactNode + disabled?: boolean +} + +interface CollapsibleMenuProps { + items: MenuItemProps[] + defaultExpandedIds?: (string | number)[] + onItemClick?: (item: MenuItemProps) => void + activeItemId?: string | number + variant?: "default" | "bordered" | "minimal" + iconPosition?: "left" | "right" +} + +// Styled components +const MenuContainer = styled.div` + width: 100%; +` + +interface MenuItemContainerProps { + level: number +} + +const MenuItemContainer = styled.div` + width: 100%; +` + +interface MenuItemButtonProps { + isActive: boolean + isDisabled: boolean + variant: "default" | "bordered" | "minimal" + level: number +} + +const MenuItemButton = styled.div` + display: flex; + align-items: center; + width: 100%; + padding: 8px 12px; + padding-left: ${(props) => props.level * 12 + 12}px; + text-align: left; + font-size: 14px; + transition: background-color 0.2s, color 0.2s; + border-radius: 6px; + + ${(props) => + props.isActive && + ` + background-color: #f3f4f6; + font-weight: 500; + `} + + ${(props) => + props.isDisabled + ? ` + opacity: 0.5; + cursor: not-allowed; + ` + : ` + cursor: pointer; + `} + + ${(props) => + props.variant === "default" && + ` + &:hover { + background-color: #f3f4f6; + } + `} + + ${(props) => + props.variant === "bordered" && + ` + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 4px; + `} + + ${(props) => + props.variant === "minimal" && + ` + &:hover { + background-color: transparent; + } + `} + + @media (prefers-color-scheme: dark) { + ${(props) => + props.isActive && + ` + background-color: #1f2937; + `} + + ${(props) => + props.variant === "default" && + ` + &:hover { + background-color: #1f2937; + } + `} + + ${(props) => + props.variant === "bordered" && + ` + border-color: #374151; + `} + } +` + +const IconWrapper = styled.span` + margin-right: 8px; +` + +const RightIconWrapper = styled.span` + margin-left: 8px; +` + +const LabelWrapper = styled.span` + flex-grow: 1; +` + +interface ToggleButtonProps { + isExpanded: boolean +} + +const ToggleButton = styled.button` + padding: 4px; + margin-left: auto; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + + &:hover { + background-color: #e5e7eb; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px #d1d5db; + } + + @media (prefers-color-scheme: dark) { + &:hover { + background-color: #374151; + } + + &:focus { + box-shadow: 0 0 0 2px #4b5563; + } + } +` + +const SubMenuContainer = styled.div` + margin-top: 4px; +` + +interface ContentContainerProps { + level: number +} + +const ContentContainer = styled.div` + padding: 8px 12px; + padding-left: ${(props) => props.level * 12 + 24}px; + margin-top: 4px; + font-size: 14px; +` + +export const CollapsibleMenu: React.FC = ({ + items, + defaultExpandedIds = [], + onItemClick, + activeItemId, + variant = "default", + iconPosition = "left", +}) => { + const [expandedItems, setExpandedItems] = useState>(new Set(defaultExpandedIds)) + + const toggleItem = (id: string | number, e: React.MouseEvent) => { + e.stopPropagation() + setExpandedItems((prev) => { + const newSet = new Set(prev) + if (newSet.has(id)) { + newSet.delete(id) + } else { + newSet.add(id) + } + return newSet + }) + } + + const handleItemClick = (item: MenuItemProps) => { + if (onItemClick && !item.disabled) { + onItemClick(item) + } + } + + const renderMenuItem = (item: MenuItemProps, level = 0) => { + const hasChildren = item.items && item.items.length > 0 + const isExpanded = expandedItems.has(item.id) + const isActive = item.id === activeItemId + + return ( + + handleItemClick(item)} + data-testid={`item-${item.id}`} + role="button" + tabIndex={item.disabled ? -1 : 0} + aria-expanded={hasChildren ? isExpanded : undefined} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleItemClick(item) + e.preventDefault() + } + }} + > + {iconPosition === "left" && item.icon && {item.icon}} + + {item.label} + + {hasChildren && ( + toggleItem(item.id, e)} + aria-label={isExpanded ? "Collapse" : "Expand"} + data-testid={`toggle-${item.id}`} + > + {isExpanded ? : } + + )} + + {iconPosition === "right" && item.icon && {item.icon}} + + + {hasChildren && isExpanded && ( + + {item.items!.map((subItem) => renderMenuItem(subItem, level + 1))} + + )} + + {item.content && isExpanded && ( + + {item.content} + + )} + + ) + } + + return ( + + {items.map((item) => renderMenuItem(item))} + + ) +}