diff --git a/ROADMAP.md b/ROADMAP.md
index 2d2c24af45..7a21851188 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -366,6 +366,14 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [x] Auto-save property changes to backend via DesignDrawer
- [x] Add Vitest tests (15 DashboardRenderer design mode + 9 DashboardEditor external selection + 8 DashboardView integration = 32 new tests)
+**Phase 7 — Non-Modal Drawer & Property Panel UX Fix:**
+- [x] `SheetContent` — added `hideOverlay` prop to conditionally skip the full-screen backdrop overlay
+- [x] `DesignDrawer` — `modal={false}` + `hideOverlay` so preview widgets are clickable while drawer is open
+- [x] `DashboardEditor` — property panel renders above widget grid (stacked `flex-col` layout) for immediate visibility in narrow drawer
+- [x] `DashboardEditor` — property panel uses full width (removed fixed `w-72`) for better readability in drawer context
+- [x] Preview click → editor property panel linkage now works end-to-end (select, switch, deselect)
+- [x] Add 11 new tests (7 DashboardDesignInteraction integration + 4 DashboardEditor.propertyPanelLayout)
+
### P1.11 Console — Schema-Driven View Config Panel Migration
> Migrated the Console ViewConfigPanel from imperative implementation (~1655 lines) to Schema-Driven architecture using `ConfigPanelRenderer` + `useConfigDraft` + `ConfigPanelSchema`, reducing to ~170 lines declarative wrapper + schema factory.
diff --git a/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx
new file mode 100644
index 0000000000..24f0888b2c
--- /dev/null
+++ b/apps/console/src/__tests__/DashboardDesignInteraction.test.tsx
@@ -0,0 +1,293 @@
+/**
+ * DashboardView Design Interaction Tests
+ *
+ * Verifies the fixes for:
+ * - Non-modal DesignDrawer allowing preview widget clicks
+ * - Property panel appearing above widget grid when a widget is selected
+ * - Click-to-select in preview area with highlight and property linkage
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { DashboardView } from '../components/DashboardView';
+
+// Track calls passed to mocked components
+const { editorCalls, rendererCalls } = vi.hoisted(() => ({
+ editorCalls: {
+ selectedWidgetId: null as string | null,
+ onWidgetSelect: null as ((id: string | null) => void) | null,
+ lastSchema: null as unknown,
+ },
+ rendererCalls: {
+ designMode: false,
+ selectedWidgetId: null as string | null,
+ onWidgetClick: null as ((id: string | null) => void) | null,
+ },
+}));
+
+// Mock MetadataProvider with a dashboard
+vi.mock('../context/MetadataProvider', () => ({
+ useMetadata: () => ({
+ apps: [],
+ objects: [],
+ dashboards: [
+ {
+ name: 'crm-dashboard',
+ type: 'dashboard',
+ title: 'CRM Overview',
+ label: 'CRM Overview',
+ columns: 2,
+ widgets: [
+ { id: 'w1', title: 'Total Revenue', type: 'metric', object: 'orders', valueField: 'amount', aggregate: 'sum' },
+ { id: 'w2', title: 'Revenue Trends', type: 'line', object: 'orders', categoryField: 'month' },
+ { id: 'w3', title: 'Pipeline by Stage', type: 'bar', object: 'opportunities' },
+ ],
+ },
+ ],
+ reports: [],
+ pages: [],
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ }),
+}));
+
+// Mock AdapterProvider
+const { mockUpdate } = vi.hoisted(() => ({ mockUpdate: vi.fn().mockResolvedValue({}) }));
+vi.mock('../context/AdapterProvider', () => ({
+ useAdapter: () => ({
+ update: mockUpdate,
+ create: vi.fn().mockResolvedValue({}),
+ }),
+}));
+
+// Mock DashboardRenderer to capture design mode props
+vi.mock('@object-ui/plugin-dashboard', () => ({
+ DashboardRenderer: (props: any) => {
+ rendererCalls.designMode = props.designMode;
+ rendererCalls.selectedWidgetId = props.selectedWidgetId;
+ rendererCalls.onWidgetClick = props.onWidgetClick;
+ return (
+
+
{String(!!props.designMode)}
+
{props.selectedWidgetId ?? 'none'}
+ {props.schema?.widgets?.map((w: any) => (
+
props.onWidgetClick?.(w.id)}
+ >
+ {w.title}
+
+ ))}
+
+ );
+ },
+}));
+
+// Mock DashboardEditor to capture selection and show property panel
+vi.mock('@object-ui/plugin-designer', () => ({
+ DashboardEditor: (props: any) => {
+ editorCalls.selectedWidgetId = props.selectedWidgetId;
+ editorCalls.onWidgetSelect = props.onWidgetSelect;
+ editorCalls.lastSchema = props.schema;
+ const widget = props.schema?.widgets?.find((w: any) => w.id === props.selectedWidgetId);
+ return (
+
+
{props.selectedWidgetId ?? 'none'}
+ {widget && (
+
+ {widget.title}
+
+ )}
+ {props.schema?.widgets?.map((w: any) => (
+
+ ))}
+
+ );
+ },
+}));
+
+// Mock sonner toast
+vi.mock('sonner', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// Mock Radix Dialog portal to render inline for testing
+vi.mock('@radix-ui/react-dialog', async () => {
+ const actual = await vi.importActual('@radix-ui/react-dialog');
+ return {
+ ...(actual as Record),
+ Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ };
+});
+
+beforeEach(() => {
+ mockUpdate.mockClear();
+ editorCalls.selectedWidgetId = null;
+ editorCalls.onWidgetSelect = null;
+ editorCalls.lastSchema = null;
+ rendererCalls.designMode = false;
+ rendererCalls.selectedWidgetId = null;
+ rendererCalls.onWidgetClick = null;
+});
+
+const renderDashboardView = async () => {
+ const result = render(
+
+
+ } />
+
+ ,
+ );
+ // Wait for the queueMicrotask loading state to resolve
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ return result;
+};
+
+const openDrawer = async () => {
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('dashboard-edit-button'));
+ });
+ // Wait for lazy-loaded DashboardEditor to resolve
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50));
+ });
+};
+
+describe('Dashboard Design Mode — Non-modal Drawer Interaction', () => {
+ it('should open drawer with non-modal behavior (no blocking overlay)', async () => {
+ await renderDashboardView();
+
+ await openDrawer();
+
+ // Drawer should be open
+ expect(screen.getByTestId('design-drawer')).toBeInTheDocument();
+ // Design mode should be enabled
+ expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('true');
+ // Both renderer and editor should be visible simultaneously
+ expect(screen.getByTestId('dashboard-renderer')).toBeInTheDocument();
+ expect(screen.getByTestId('dashboard-editor')).toBeInTheDocument();
+ });
+
+ it('should allow clicking preview widgets while drawer is open', async () => {
+ await renderDashboardView();
+ await openDrawer();
+
+ // Click widget in preview area — this verifies the drawer doesn't block clicks
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('renderer-widget-w1'));
+ });
+
+ // Widget should be selected in both renderer and editor
+ expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1');
+ expect(screen.getByTestId('editor-selected')).toHaveTextContent('w1');
+ });
+
+ it('should show property panel in editor when preview widget is clicked', async () => {
+ await renderDashboardView();
+ await openDrawer();
+
+ // Click widget in preview
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('renderer-widget-w1'));
+ });
+
+ // Property panel should show the selected widget's properties
+ expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
+ expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue');
+ });
+
+ it('should show property panel when clicking editor widget list item', async () => {
+ await renderDashboardView();
+ await openDrawer();
+
+ // Click widget in editor list
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('editor-widget-w2'));
+ });
+
+ // Property panel should show for the clicked widget
+ expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
+ expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Revenue Trends');
+ // Preview should also reflect the selection
+ expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w2');
+ });
+
+ it('should switch selection between different widgets', async () => {
+ await renderDashboardView();
+ await openDrawer();
+
+ // Select w1
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('renderer-widget-w1'));
+ });
+ expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Total Revenue');
+
+ // Switch to w3
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('renderer-widget-w3'));
+ });
+ expect(screen.getByTestId('editor-widget-title')).toHaveTextContent('Pipeline by Stage');
+ expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w3');
+ });
+
+ it('should deselect when clicking empty space in preview', async () => {
+ await renderDashboardView();
+ await openDrawer();
+
+ // Select a widget
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('renderer-widget-w1'));
+ });
+ expect(screen.getByTestId('editor-property-panel')).toBeInTheDocument();
+
+ // Deselect by calling onWidgetClick(null) (simulates background click)
+ await act(async () => {
+ rendererCalls.onWidgetClick?.(null);
+ });
+
+ // Property panel should be hidden
+ expect(screen.queryByTestId('editor-property-panel')).not.toBeInTheDocument();
+ expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
+ });
+
+ it('should clear selection when drawer is closed', async () => {
+ await renderDashboardView();
+
+ // Open drawer and select
+ await openDrawer();
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('renderer-widget-w1'));
+ });
+ expect(screen.getByTestId('renderer-selected')).toHaveTextContent('w1');
+
+ // Close the drawer
+ const closeButtons = screen.getAllByRole('button', { name: /close/i });
+ const sheetCloseBtn = closeButtons.find((btn) =>
+ btn.closest('[data-testid="design-drawer"]'),
+ );
+ if (sheetCloseBtn) {
+ await act(async () => {
+ fireEvent.click(sheetCloseBtn);
+ });
+ }
+
+ // Selection should be cleared
+ expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false');
+ expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
+ });
+});
diff --git a/apps/console/src/components/DesignDrawer.tsx b/apps/console/src/components/DesignDrawer.tsx
index 6d2a09356a..219c1c36b8 100644
--- a/apps/console/src/components/DesignDrawer.tsx
+++ b/apps/console/src/components/DesignDrawer.tsx
@@ -93,9 +93,10 @@ export function DesignDrawer({
}, [open, saveSchema, title]);
return (
-
+
diff --git a/packages/components/src/ui/sheet.tsx b/packages/components/src/ui/sheet.tsx
index 5c21aa1a7f..43655e26c2 100644
--- a/packages/components/src/ui/sheet.tsx
+++ b/packages/components/src/ui/sheet.tsx
@@ -59,14 +59,17 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef,
- VariantProps {}
+ VariantProps {
+ /** When true, the backdrop overlay is not rendered (useful for non-modal drawers) */
+ hideOverlay?: boolean;
+}
const SheetContent = React.forwardRef<
React.ElementRef,
SheetContentProps
->(({ side = "right", className, children, ...props }, ref) => (
+>(({ side = "right", className, children, hideOverlay, ...props }, ref) => (
-
+ {!hideOverlay && }
{t('appDesigner.widgetProperties')}
@@ -572,7 +572,7 @@ export function DashboardEditor({
ref={containerRef}
tabIndex={0}
data-testid="dashboard-editor"
- className={cn('flex flex-col gap-4 outline-none sm:flex-row', className)}
+ className={cn('flex flex-col gap-4 outline-none', className)}
>
{/* Hidden file input for import */}
+ {/* Property panel — shown above widget list when a widget is selected */}
+ {selectedWidget && !previewMode && (
+
setSelectedWidgetId(null)}
+ />
+ )}
+
{/* Main area */}
{/* Toolbar */}
@@ -695,16 +705,6 @@ export function DashboardEditor({
)}
-
- {/* Property panel */}
- {selectedWidget && !previewMode && (
- setSelectedWidgetId(null)}
- />
- )}
);
}
diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.propertyPanelLayout.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.propertyPanelLayout.test.tsx
new file mode 100644
index 0000000000..bab7ce4e3d
--- /dev/null
+++ b/packages/plugin-designer/src/__tests__/DashboardEditor.propertyPanelLayout.test.tsx
@@ -0,0 +1,91 @@
+/**
+ * DashboardEditor Property Panel Layout Tests
+ *
+ * Verifies that the property panel renders above the widget grid
+ * so it's immediately visible in the narrow DesignDrawer context.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { DashboardEditor } from '../DashboardEditor';
+import type { DashboardSchema } from '@object-ui/types';
+
+const DASHBOARD_WITH_WIDGETS: DashboardSchema = {
+ type: 'dashboard',
+ title: 'Test Dashboard',
+ columns: 2,
+ widgets: [
+ { id: 'w1', title: 'Revenue', type: 'metric', object: 'orders', valueField: 'amount', aggregate: 'sum' },
+ { id: 'w2', title: 'Sales Chart', type: 'bar', object: 'orders', categoryField: 'month' },
+ ],
+};
+
+describe('DashboardEditor property panel layout', () => {
+ it('should render property panel before widget grid in DOM order when widget is selected', () => {
+ const { container } = render(
+ ,
+ );
+
+ const editor = screen.getByTestId('dashboard-editor');
+ const propertyPanel = screen.getByTestId('widget-property-panel');
+ const widgetCard = screen.getByTestId('dashboard-widget-w1');
+
+ // Property panel should appear before the widget cards in DOM order
+ // compareDocumentPosition bit 4 (DOCUMENT_POSITION_FOLLOWING) means widgetCard comes after propertyPanel
+ const position = propertyPanel.compareDocumentPosition(widgetCard);
+ expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('should render property panel as a direct child of the editor container', () => {
+ render(
+ ,
+ );
+
+ const editor = screen.getByTestId('dashboard-editor');
+ const propertyPanel = screen.getByTestId('widget-property-panel');
+
+ // Property panel should be a direct child of the editor (not nested inside main area)
+ expect(propertyPanel.parentElement).toBe(editor);
+ });
+
+ it('should use vertical (flex-col) layout for the editor', () => {
+ render(
+ ,
+ );
+
+ const editor = screen.getByTestId('dashboard-editor');
+ // Should use flex-col layout, NOT sm:flex-row
+ expect(editor.className).toContain('flex-col');
+ expect(editor.className).not.toContain('flex-row');
+ });
+
+ it('should not have fixed width on property panel (uses full width in stacked layout)', () => {
+ render(
+ ,
+ );
+
+ const propertyPanel = screen.getByTestId('widget-property-panel');
+ // Should NOT have the old w-72 fixed width class
+ expect(propertyPanel.className).not.toContain('w-72');
+ });
+});