From 165f91befb14d08f78de5f24500c8377ebfe4ed8 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Mon, 23 Feb 2026 16:24:03 +0600 Subject: [PATCH 1/2] Show menu icon on collapsed modeand add action for toogle menu --- src/components/ui/Layout.stories.tsx | 108 ++++++++- src/components/ui/index.ts | 1 + src/components/ui/layout-menu.tsx | 316 +++++++++++++++++++++++++-- src/components/ui/layout.tsx | 78 ++++--- 4 files changed, 454 insertions(+), 49 deletions(-) diff --git a/src/components/ui/Layout.stories.tsx b/src/components/ui/Layout.stories.tsx index 047d48c..f8cde12 100644 --- a/src/components/ui/Layout.stories.tsx +++ b/src/components/ui/Layout.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { doAction } from "@wordpress/hooks"; import { BarChart3, FileText, @@ -139,7 +140,9 @@ export const WithControls: Story = { ); return ( @@ -403,7 +406,7 @@ export const FullLayout: Story = {

- +
- + ( + + + + + + + + Sidebar on left + + +
+

Main content

+

+ Sidebar is on the left. Put LayoutSidebar before LayoutMain in + LayoutBody for left position. +

+
+
+
+ + © 2025 + +
+ ), +}; /** Customize hover/focus and active (selected) colors via menuItemClassName and activeItemClassName. */ export const CustomHoverAndActiveStyles: Story = { render: () => ( @@ -474,7 +510,7 @@ export const CustomHoverAndActiveStyles: Story = { activeItemClassName (e.g. primary colors).

- + ( +
+ + + + WordPress Hooks Demo + + + + + + +
+

Toggle sidebar via WordPress hooks

+

+ The "External toggle" button calls{" "} + doAction("dokan_layout_toggle") to toggle + the sidebar. This allows external WordPress code to control the + sidebar without direct access to React state. +

+
+                {`import { doAction } from "@wordpress/hooks";\n\n// Toggle the sidebar from anywhere\ndoAction("dokan_layout_toggle");`}
+              
+
+
+
+
+
+ ), +}; + /** Menu data types for `items` and `groups` props. Use with `LayoutMenu`. */ export const MenuDataStructure: Story = { parameters: { diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 67beabb..f720739 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -195,6 +195,7 @@ export { LayoutMain, LayoutSidebar, LayoutFooter, + useLayout, type LayoutProps, type LayoutHeaderProps, type LayoutBodyProps, diff --git a/src/components/ui/layout-menu.tsx b/src/components/ui/layout-menu.tsx index 81f0e14..085ef7e 100644 --- a/src/components/ui/layout-menu.tsx +++ b/src/components/ui/layout-menu.tsx @@ -12,6 +12,7 @@ import { type ReactNode, } from "react"; import { Input } from "./input"; +import { useLayout } from "./layout"; /* ============================================ Types: multi-label nested menu items @@ -42,6 +43,18 @@ export interface LayoutMenuGroupData { className?: string; } +/* ============================================ + Safe layout hook (fallback for standalone use) + ============================================ */ + +function useLayoutSafe() { + try { + return useLayout(); + } catch { + return null; + } +} + /* ============================================ Filter nested items by search query ============================================ */ @@ -189,6 +202,9 @@ export const LayoutMenu = forwardRef( ) => { const [search, setSearch] = useState(""); const menuContainerRef = useRef(null); + const layout = useLayoutSafe(); + const collapsed = layout ? !layout.sidebarOpen : false; + const isLeft = layout?.sidebarPosition === "left"; const filteredItems = useMemo( () => (items ? filterMenuItems(items, search) : []), @@ -285,10 +301,14 @@ export const LayoutMenu = forwardRef(
- {searchable && ( + {searchable && !collapsed && ( ( )}
( ( -
- {renderGroupLabel ? ( - renderGroupLabel(group) - ) : ( - <> - {group.label} - {group.secondaryLabel && ( - - {group.secondaryLabel} - - )} - - )} -
+ {!collapsed && ( +
+ {renderGroupLabel ? ( + renderGroupLabel(group) + ) : ( + <> + {group.label} + {group.secondaryLabel && ( + + {group.secondaryLabel} + + )} + + )} +
+ )} + {collapsed && ( +
+ )} + + {/* Flyout panel on hover — only for items with children */} + {hasChildren && ( +
+
+ {/* Parent label as flyout header */} +
+ {item.label} +
+ {/* Submenu items */} +
+ +
+
+
+ )} + + ); + } + + // Collapsed mode: hide nested items (they appear in flyout) + if (collapsed && depth > 0) { + return null; + } + const content = renderItem ? ( renderItem(item, depth) ) : ( @@ -551,6 +654,179 @@ function LayoutMenuItemNode({ ); } +/* ============================================ + CollapsedMenuItemIcon (icon-only button for collapsed rail) + ============================================ */ + +interface CollapsedMenuItemIconProps { + item: LayoutMenuItemData; + isActive: boolean; + menuItemClassName?: string; + activeItemClassName?: string; + onItemClick?: (item: LayoutMenuItemData) => void; +} + +function CollapsedMenuItemIcon({ + item, + isActive, + menuItemClassName, + activeItemClassName, + onItemClick, +}: CollapsedMenuItemIconProps) { + const handleClick = useCallback(() => { + item.onClick?.(); + onItemClick?.(item); + }, [item, onItemClick]); + + const Comp = item.href ? "a" : "button"; + const compProps = + item.href + ? { + href: item.href, + onClick: () => onItemClick?.(item), + } + : { + type: "button" as const, + onClick: handleClick, + }; + + return ( + + {item.icon ? ( + {item.icon} + ) : ( + + {item.label.charAt(0).toUpperCase()} + + )} + + ); +} + +/* ============================================ + FlyoutMenuItemList (flat list inside flyout panel) + ============================================ */ + +interface FlyoutMenuItemListProps { + items: LayoutMenuItemData[]; + onItemClick?: (item: LayoutMenuItemData) => void; + menuItemClassName?: string; + activeItemClassName?: string; + activeItemId?: string | null; +} + +function FlyoutMenuItemList({ + items, + onItemClick, + menuItemClassName, + activeItemClassName, + activeItemId, +}: FlyoutMenuItemListProps) { + return ( +
    + {items.map((item) => ( + + ))} +
+ ); +} + +interface FlyoutMenuItemProps { + item: LayoutMenuItemData; + onItemClick?: (item: LayoutMenuItemData) => void; + menuItemClassName?: string; + activeItemClassName?: string; + activeItemId?: string | null; +} + +function FlyoutMenuItem({ + item, + onItemClick, + menuItemClassName, + activeItemClassName, + activeItemId, +}: FlyoutMenuItemProps) { + const isActive = activeItemId != null && item.id === activeItemId; + const hasChildren = item.children && item.children.length > 0; + + const handleClick = useCallback(() => { + item.onClick?.(); + onItemClick?.(item); + }, [item, onItemClick]); + + const Comp = item.href ? "a" : "button"; + const compProps = + item.href + ? { + href: item.href, + onClick: () => onItemClick?.(item), + } + : { + type: "button" as const, + onClick: handleClick, + }; + + return ( +
  • + + {item.icon && ( + {item.icon} + )} + {item.label} + + {hasChildren && ( + + )} +
  • + ); +} + /* ============================================ LayoutMenuItemRow (default row: icon, labels, chevron) ============================================ */ diff --git a/src/components/ui/layout.tsx b/src/components/ui/layout.tsx index 4463921..e046996 100644 --- a/src/components/ui/layout.tsx +++ b/src/components/ui/layout.tsx @@ -4,12 +4,14 @@ import { createContext, forwardRef, useContext, + useEffect, useState, type HTMLAttributes, type ReactNode, } from "react"; import { Button } from "./button"; +import { addAction, removeAction } from "@wordpress/hooks"; /* ============================================ Layout context (sidebar open state, breakpoint) ============================================ */ @@ -26,11 +28,13 @@ export interface LayoutContextValue { sidebarBreakpoint: string; sidebarPosition: LayoutSidebarPosition; isMobile: boolean; + /** Unique namespace for WordPress hooks (e.g. "dokan"). Used to register actions like `{namespace}_layout_toggle`. */ + namespace: string; } const LayoutContext = createContext(null); -function useLayout() { +export function useLayout() { const ctx = useContext(LayoutContext); if (!ctx) throw new Error("Layout subcomponents must be used within Layout."); return ctx; @@ -51,6 +55,8 @@ export interface LayoutProps extends HTMLAttributes { sidebarBreakpoint?: string; /** Initial open state for mobile sidebar */ defaultSidebarOpen?: boolean; + /** Unique namespace for WordPress hooks integration. Used to register actions like `{namespace}_layout_toggle`. */ + namespace?: string; } export const Layout = forwardRef( @@ -61,10 +67,11 @@ export const Layout = forwardRef( sidebarVariant = "drawer", sidebarBreakpoint = "lg", defaultSidebarOpen = false, + namespace = "plugin_ui", children, ...props }, - ref + ref, ) => { const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const value: LayoutContextValue = { @@ -74,6 +81,7 @@ export const Layout = forwardRef( sidebarBreakpoint, sidebarPosition: sidebarPosition ?? null, isMobile: true, // will be set by CSS/JS or consumer can override via provider + namespace, }; return ( @@ -87,7 +95,7 @@ export const Layout = forwardRef(
    ); - } + }, ); Layout.displayName = "Layout"; @@ -105,7 +113,22 @@ export interface LayoutHeaderProps extends HTMLAttributes { export const LayoutHeader = forwardRef( ({ className, children, showSidebarToggle = true, ...props }, ref) => { - const { setSidebarOpen, sidebarPosition } = useLayout(); + const { setSidebarOpen, sidebarPosition, namespace } = useLayout(); + + const toggleSideBar = () => { + setSidebarOpen((prev) => !prev); + }; + + // Register WordPress hook so external code can toggle the sidebar + // via `doAction('{namespace}_layout_toggle')` + useEffect(() => { + const hookName = `${namespace}_layout_toggle`; + addAction(hookName, namespace, toggleSideBar); + return () => { + removeAction(hookName, namespace); + }; + }); + const hasSidebar = sidebarPosition === "left" || sidebarPosition === "right"; return ( @@ -114,7 +137,7 @@ export const LayoutHeader = forwardRef( data-slot="layout-header" className={cn( "sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background px-4", - className + className, )} {...props} > @@ -123,7 +146,7 @@ export const LayoutHeader = forwardRef( variant="ghost" size="icon" className="shrink-0" - onClick={() => setSidebarOpen((prev) => !prev)} + onClick={toggleSideBar} aria-label="Toggle sidebar" > @@ -132,7 +155,7 @@ export const LayoutHeader = forwardRef( {children} ); - } + }, ); LayoutHeader.displayName = "LayoutHeader"; @@ -158,7 +181,7 @@ export const LayoutBody = forwardRef( {children}
    ); - } + }, ); LayoutBody.displayName = "LayoutBody"; @@ -180,14 +203,14 @@ export const LayoutMain = forwardRef( data-slot="layout-main" className={cn( "min-w-0 flex-1 overflow-auto p-4 focus:outline-none", - className + className, )} {...props} > {children} ); - } + }, ); LayoutMain.displayName = "LayoutMain"; @@ -205,12 +228,8 @@ export interface LayoutSidebarProps extends HTMLAttributes { export const LayoutSidebar = forwardRef( ({ className, children, width = "w-72", ...props }, ref) => { - const { - sidebarOpen, - setSidebarOpen, - sidebarBreakpoint, - sidebarPosition, - } = useLayout(); + const { sidebarOpen, setSidebarOpen, sidebarBreakpoint, sidebarPosition } = + useLayout(); if (sidebarPosition !== "left" && sidebarPosition !== "right") { return null; @@ -222,10 +241,10 @@ export const LayoutSidebar = forwardRef( bp === "lg" ? "lg:flex" : bp === "md" ? "md:flex" : "xl:flex"; const collapseWhenClosed = bp === "lg" - ? "lg:w-0 lg:min-w-0 lg:overflow-hidden" + ? "lg:w-12 lg:min-w-12" : bp === "md" - ? "md:w-0 md:min-w-0 md:overflow-hidden" - : "xl:w-0 xl:min-w-0 xl:overflow-hidden"; + ? "md:w-12 md:min-w-12" + : "xl:w-12 xl:min-w-12"; const desktopTransition = bp === "lg" ? "lg:transition-[width] lg:duration-200" @@ -240,7 +259,7 @@ export const LayoutSidebar = forwardRef( className={cn( "fixed inset-0 z-40 bg-black/50 lg:hidden", "transition-opacity duration-300 ease-out", - sidebarOpen ? "opacity-100" : "pointer-events-none opacity-0" + sidebarOpen ? "opacity-100" : "pointer-events-none opacity-0", )} aria-hidden onClick={() => setSidebarOpen(false)} @@ -266,11 +285,13 @@ export const LayoutSidebar = forwardRef( !sidebarOpen && (isLeft ? "max-lg:-translate-x-full" : "max-lg:translate-x-full"), "max-lg:transition-transform max-lg:duration-300 max-lg:ease-out", + !sidebarOpen && "lg:overflow-visible", breakpointClass, - className + className, )} {...props} > + {/* Mobile close button */}
    -
    +
    {children}
    ); - } + }, ); LayoutSidebar.displayName = "LayoutSidebar"; @@ -310,14 +338,14 @@ export const LayoutFooter = forwardRef( data-slot="layout-footer" className={cn( "shrink-0 border-t border-border bg-muted/30 px-4 py-3", - className + className, )} {...props} > {children} ); - } + }, ); LayoutFooter.displayName = "LayoutFooter"; From 7432e7c49153897e1741513142cd90219d24ec64 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 25 Feb 2026 11:27:48 +0600 Subject: [PATCH 2/2] fix: resolve useEffect missing deps, flyout accessibility, and code quality issues - Add dependency array to sidebar toggle useEffect to prevent re-registering on every render - Wrap toggleSideBar in useCallback for stable reference - Add focus-within support to flyout menus for keyboard accessibility - Replace try/catch useLayoutSafe with proper useLayoutOptional hook - Fix minor formatting (extra space, missing space, double blank line) Co-Authored-By: Claude Opus 4.6 --- src/components/ui/index.ts | 1 + src/components/ui/layout-menu.tsx | 17 +++----------- src/components/ui/layout.tsx | 38 +++++++++++++++++++------------ 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index f720739..1ad99c7 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -196,6 +196,7 @@ export { LayoutSidebar, LayoutFooter, useLayout, + useLayoutOptional, type LayoutProps, type LayoutHeaderProps, type LayoutBodyProps, diff --git a/src/components/ui/layout-menu.tsx b/src/components/ui/layout-menu.tsx index 085ef7e..2edea10 100644 --- a/src/components/ui/layout-menu.tsx +++ b/src/components/ui/layout-menu.tsx @@ -12,7 +12,7 @@ import { type ReactNode, } from "react"; import { Input } from "./input"; -import { useLayout } from "./layout"; +import { useLayoutOptional } from "./layout"; /* ============================================ Types: multi-label nested menu items @@ -43,18 +43,6 @@ export interface LayoutMenuGroupData { className?: string; } -/* ============================================ - Safe layout hook (fallback for standalone use) - ============================================ */ - -function useLayoutSafe() { - try { - return useLayout(); - } catch { - return null; - } -} - /* ============================================ Filter nested items by search query ============================================ */ @@ -202,7 +190,7 @@ export const LayoutMenu = forwardRef( ) => { const [search, setSearch] = useState(""); const menuContainerRef = useRef(null); - const layout = useLayoutSafe(); + const layout = useLayoutOptional(); const collapsed = layout ? !layout.sidebarOpen : false; const isLeft = layout?.sidebarPosition === "left"; @@ -583,6 +571,7 @@ function LayoutMenuItemNode({ className={cn( "pointer-events-none invisible absolute top-0 z-50 min-w-44 opacity-0 transition-[opacity,visibility] duration-150", "group-hover/flyout:pointer-events-auto group-hover/flyout:visible group-hover/flyout:opacity-100", + "group-focus-within/flyout:pointer-events-auto group-focus-within/flyout:visible group-focus-within/flyout:opacity-100", isLeft ? "left-full ml-1" : "right-full mr-1" )} > diff --git a/src/components/ui/layout.tsx b/src/components/ui/layout.tsx index e046996..8cc9d84 100644 --- a/src/components/ui/layout.tsx +++ b/src/components/ui/layout.tsx @@ -3,6 +3,7 @@ import { Menu, X } from "lucide-react"; import { createContext, forwardRef, + useCallback, useContext, useEffect, useState, @@ -11,7 +12,7 @@ import { } from "react"; import { Button } from "./button"; -import { addAction, removeAction } from "@wordpress/hooks"; +import { addAction, doAction, removeAction } from "@wordpress/hooks"; /* ============================================ Layout context (sidebar open state, breakpoint) ============================================ */ @@ -40,6 +41,11 @@ export function useLayout() { return ctx; } +/** Returns layout context or null when used outside a Layout provider. */ +export function useLayoutOptional() { + return useContext(LayoutContext); +} + /* ============================================ Layout Root ============================================ */ @@ -113,22 +119,12 @@ export interface LayoutHeaderProps extends HTMLAttributes { export const LayoutHeader = forwardRef( ({ className, children, showSidebarToggle = true, ...props }, ref) => { - const { setSidebarOpen, sidebarPosition, namespace } = useLayout(); + const { sidebarPosition, namespace } = useLayout(); const toggleSideBar = () => { - setSidebarOpen((prev) => !prev); + doAction(`${namespace}_layout_toggle`); }; - // Register WordPress hook so external code can toggle the sidebar - // via `doAction('{namespace}_layout_toggle')` - useEffect(() => { - const hookName = `${namespace}_layout_toggle`; - addAction(hookName, namespace, toggleSideBar); - return () => { - removeAction(hookName, namespace); - }; - }); - const hasSidebar = sidebarPosition === "left" || sidebarPosition === "right"; return ( @@ -228,9 +224,23 @@ export interface LayoutSidebarProps extends HTMLAttributes { export const LayoutSidebar = forwardRef( ({ className, children, width = "w-72", ...props }, ref) => { - const { sidebarOpen, setSidebarOpen, sidebarBreakpoint, sidebarPosition } = + const { sidebarOpen, setSidebarOpen, sidebarBreakpoint, sidebarPosition, namespace } = useLayout(); + const toggleSideBar = useCallback(() => { + setSidebarOpen((prev) => !prev); + }, [setSidebarOpen]); + + // Register WordPress hook so external code can toggle the sidebar + // via `doAction('{namespace}_layout_toggle')` + useEffect(() => { + const hookName = `${namespace}_layout_toggle`; + addAction(hookName, namespace, toggleSideBar); + return () => { + removeAction(hookName, namespace); + }; + }, [namespace, toggleSideBar]); + if (sidebarPosition !== "left" && sidebarPosition !== "right") { return null; }