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
+
+ Sign out
+
+
+
+
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).
-
+
(
+
+
doAction("dokan_layout_toggle")}
+ >
+ External toggle (doAction)
+
+
+
+ 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;
}