Widget Rail Surface System
macOS-Dock-style WidgetRail + opt-in widgetSurface export pattern for miniapps alongside the patient profile.
macOS-Dock-style configurable sidebar for patient profiles — lets nurses track active orders, lab results, and other departmental signals at a glance without switching tabs.
Concept
The Widget Rail extends the existing miniapp pattern with an opt-in widget surface — a small, glanceable representation of a miniapp that renders in a narrow icon dock alongside the patient profile. Clicking an icon opens a 320px flyout panel; clicking “Open full tab” promotes it to the full miniapp tab (the existing DynamicContentRenderer slot).
┌─────────────────────────────────────────────────┬────┐
│ │ ▦ │ ← Widget Extensions header
│ Patient Profile Content │────│
│ (tabs rendered via DynamicContentRenderer) │ 📋 │ ← OrderActivityDock icon (badge: 5)
│ │ 🧪 │ ← LabResults icon (badge: 2)
│ │ │
│ │ │
│ │ │
└─────────────────────────────────────────────────┴────┘
52px
When an icon is clicked:
┌──────────────────────────────────┬──────────┬────┐
│ │ ↗ ╳ │ │
│ Patient Profile Content │──────────│ ▦ │
│ │ Flyout │────│
│ │ content │ 📋 │ ← active (highlighted)
│ │ 320px │ 🧪 │
│ │ │ │
│ │ │ │
└──────────────────────────────────┴──────────┴────┘
Architecture
Three Layers
| Layer | Location | Responsibility |
|---|---|---|
| WidgetRail (ui-kit) | web/packages/ui-kit/src/components/widget-rail/ |
Generic dock + flyout chrome. No domain knowledge. |
| Widget surfaces (miniapps) | Each miniapp’s index.ts → widgetSurface export |
Domain-specific compact widget UI. |
| Host (patient profile) | Consumer component that renders `` | Provides encounter/patient context, wires onOpenTab. |
File Inventory
| File | Purpose |
|---|---|
web/packages/ui-kit/src/components/widget-rail/types.ts |
WidgetSurface, WidgetSurfaceContext, WidgetRailConfig, WidgetRailProps |
web/packages/ui-kit/src/components/widget-rail/WidgetRail.tsx |
Dock + flyout component (~240 lines) |
web/packages/ui-kit/src/components/widget-rail/index.ts |
Barrel exports |
web/packages/miniapps/order-activity-dock/types.ts |
OrderType, OrderStatus, ActiveOrder, ORDER_TYPE_META, STATUS_META |
web/packages/miniapps/order-activity-dock/OrderActivityDock.tsx |
Active orders widget — grouped by dept type, status chips, progress bar |
web/packages/miniapps/order-activity-dock/index.ts |
Barrel + widgetSurface export |
web/packages/miniapps/laboratory-results/LabResultsWidget.tsx |
Lab results widget — pending/completed panels, specimen stage dots |
web/packages/miniapps/laboratory-results/index.ts |
Updated — now exports widgetSurface |
web/sandbox/targets/WidgetRailTarget.tsx |
Sandbox demo with mock patient profile |
WidgetSurface Contract
Any miniapp can opt in by exporting a widgetSurface object from its barrel
file. The type:
interface WidgetSurface {
id: string; // unique widget identifier
labelTh: string; // Thai display name
labelEn: string; // English display name
Icon: FC; // icon component (rendered in dock)
priority?: number; // sort order (lower = higher, default 50)
defaultEnabled?: boolean; // shown by default? (default true)
badge?: FC<WidgetSurfaceContext>; // optional dynamic badge component
Widget: FC<WidgetSurfaceContext & { onClose; onOpenTab? }>; // flyout content
}
Important: Icon must be an FC, not ReactNode
The Icon field is a component (FC), not a pre-rendered element
(ReactNode). This avoids the sandbox dual-React issue where MUI icons
created at module scope via React.createElement() bind to the wrong React
copy in Vite’s pre-bundled dependency graph.
// CORRECT — icon renders inside the React tree
const MyIcon: React.FC = () =>
React.createElement(ScienceIcon, { sx: { fontSize: 20 } });
// WRONG — creates element at module scope, dual-React in sandbox
icon: React.createElement(ScienceIcon, { sx: { fontSize: 20 } })
In .ts barrel files (not .tsx), use React.createElement inside the
component body. In .tsx files, JSX is fine.
Lazy Loading
The Widget field should use React.lazy() so flyout content is only loaded
when the user clicks the icon:
Widget: React.lazy(() => import('./LabResultsWidget')),
The WidgetRail wraps each flyout’s content area in its own `` boundary with a spinner fallback, so lazy widgets don’t unmount the entire rail.
WidgetRail Configuration
The host component passes a config prop:
interface WidgetRailConfig {
position?: 'right' | 'left'; // which edge (default: right)
collapsedWidth?: number; // icon dock width (default: 52)
expandedWidth?: number; // flyout panel width (default: 320)
enabledWidgets?: string[]; // whitelist by id (default: all with defaultEnabled)
autoCollapse?: boolean; // reserved for future use
}
Data Flow
OrderActivityDock
Currently uses sample data in sandbox mode. In production, it will:
- Subscribe to
department_queuesvia Supabase realtime, filtering byencounter_idfor the current patient. - Group rows by
dept_type, mapstatustoOrderStatus. - Show active count badge on the dock icon.
department_queues (Supabase realtime)
→ filter by encounter_id
→ group by dept_type
→ OrderActivityDock renders grouped list
LabResultsWidget
Uses SAMPLE_PANELS from the laboratory-results miniapp. In production:
- Query
encounter_journey_cache.clinical_context.specimen_pipelinefor specimen stage data. - Query lab panels from the diagnostic service API.
- Show count of unverified/critical panels as badge.
Adding a New Widget
-
Create the widget component in your miniapp package:
web/packages/miniapps/my-module/MyModuleWidget.tsxMust accept
WidgetSurfaceContext & { onClose; onOpenTab? }props. -
Export
widgetSurfacefrom your miniapp’sindex.ts:import React from 'react'; import MyIcon from '@mui/icons-material/MyIcon'; import type { WidgetSurface } from '@ui-kit/components/widget-rail'; const Icon: React.FC = () => React.createElement(MyIcon, { sx: { fontSize: 20 } }); export const widgetSurface: WidgetSurface = { id: 'my-module', labelTh: 'ชื่อภาษาไทย', labelEn: 'English Name', Icon, priority: 30, Widget: React.lazy(() => import('./MyModuleWidget')), }; -
Register in the host — the consumer component (patient profile) collects all
widgetSurfaceexports and passes them to `` -
Test in sandbox — add your widget surface to
web/sandbox/targets/WidgetRailTarget.tsx:import { widgetSurface as myModuleSurface } from '@miniapps/my-module'; const WIDGETS = [orderDockSurface, labResultsSurface, myModuleSurface];
Existing Widgets
| Widget | id | Priority | Badge | Source |
|---|---|---|---|---|
| Active Orders | order-activity-dock |
10 | Active order count (orange) | @miniapps/order-activity-dock |
| Lab Results | laboratory-results |
20 | Critical/pending count (red) | @miniapps/laboratory-results |
Candidate Future Widgets
| Module | Widget idea |
|---|---|
| Pharmacy | Pending prescriptions + dispensing status |
| Imaging | Study status (ordered → scheduled → acquired → reported) |
| Blood Bank | Crossmatch status + reservation timer |
| Nursing | Pending nursing tasks / assessments due |
| Vital Signs | Latest vitals + CDS alert count |
| Acknowledgements | Unread ack requests for this patient |
Integration with Patient Profile
The WidgetRail is designed to sit alongside the patient profile’s tab content area. The host layout uses a flex row:
<Box sx={{ display: 'flex', height: '100%' }}>
{/* Main tab content */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
<DynamicContentRenderer ... />
</Box>
{/* Widget rail on the right edge */}
<Suspense fallback={<Box sx={{ width: 52 }} />}>
<WidgetRail
encounterId={encounter.id}
patientId={patient.id}
locale={locale}
widgets={registeredWidgets}
config={{ position: 'right' }}
onOpenTab={(moduleId) => openTab(moduleId)}
/>
</Suspense>
</Box>
The onOpenTab callback lets the widget request promotion to a full miniapp
tab — the host maps the widget id to the corresponding DynamicCoreApp
module and opens it.
Design Decisions
-
Opt-in, not opt-out — miniapps must explicitly export
widgetSurface. No auto-discovery or convention-based registration. -
Icon is FC, not ReactNode — prevents the Vite sandbox dual-React
useContextcrash that occurs when MUI icons are instantiated at module-import time. -
Lazy Widget loading — flyout content is
React.lazywith per-widgetSuspenseboundary inside the rail, so loading one widget doesn’t unmount/flash the entire dock. -
No direct data writes — widgets are read-only surfaces. Actions route through the full miniapp tab or backend APIs.
-
Bilingual throughout —
labelTh+labelEnon every surface, consistent with the project’s i18n pattern. -
Configurable per deployment —
enabledWidgetsinWidgetRailConfiglets each hospital/region control which widgets appear.