feat(CustomizableDashboard): draggable multi-column portlet dashboard - #389
feat(CustomizableDashboard): draggable multi-column portlet dashboard#389ebellamy-bh wants to merge 3 commits into
Conversation
Ported from echart-sim's Dashboard shell: @dnd-kit cross-column drag, 1/2/3-column layout toggle with auto-shrink, localStorage persistence via storageKey (echart-sim-compatible keys), and controlled order/layout props for server-side persistence. Drag handles portal into the DashboardWidget header slot (configurable selector) with a floating fallback. Adds @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities as regular dependencies.
There was a problem hiding this comment.
Pull request overview
Adds CustomizableDashboard, a reusable draggable 1–3 column dashboard with persistence, responsive layouts, controlled state, and accessibility support.
Changes:
- Adds dnd-kit drag-and-drop and layout controls.
- Adds localStorage and controlled persistence APIs.
- Adds exports, build configuration, dependencies, tests, stories, and documentation.
- Requires fixes for drag ordering, column-gap handling, hydration, persistence validation, and side-effect placement.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
tsup.config.ts |
Adds the component build entry. |
src/index.ts |
Exports the dashboard publicly. |
src/components/CustomizableDashboard/MAINTAINERS.md |
Documents coupling and tradeoffs. |
src/components/CustomizableDashboard/index.ts |
Exports component APIs and types. |
src/components/CustomizableDashboard/CustomizableDashboard.tsx |
Implements dashboard functionality. |
src/components/CustomizableDashboard/CustomizableDashboard.test.tsx |
Covers reconciliation and core rendering behavior. |
src/components/CustomizableDashboard/CustomizableDashboard.stories.tsx |
Adds usage examples and variants. |
pnpm-lock.yaml |
Locks dependency versions. |
package.json |
Adds dnd-kit dependencies. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (5)
src/components/CustomizableDashboard/CustomizableDashboard.tsx:621
layoutandorderare documented as independently controllable/mixable, but this render path simply omits later logical columns for layout 1/2. Thuslayout={1}withorder={[['a'], ['b'], ['c']]}(or persisted data from a different layout) renders onlyaand silently dropsb/c. Normalize the effective order for the selected layout, including controlled and initially restored values, before rendering visible columns.
const visibleCols =
layoutMode === 1 ? [cols[0]] : layoutMode === 2 ? [cols[0], cols[1]] : cols;
src/components/CustomizableDashboard/CustomizableDashboard.tsx:601
commitOrderperforms localStorage writes, invokes consumer callbacks, and may callsetLayoutfrom inside a functionalsetColOrderupdater. React may evaluate such updaters more than once or replay them in Strict Mode/concurrent rendering, causing duplicate persistence/callbacks or side effects for an order that is not ultimately committed. Keep the updater pure and run the commit after resolving the next state.
commitOrder(next);
src/components/CustomizableDashboard/CustomizableDashboard.tsx:248
- The sortable activator is a separate portaled button, while
setNodeRefis attached only to the wrapper. Attach thesetActivatorNodeRefreturned byuseSortableto that button as well; otherwise dnd-kit cannot reliably manage focus restoration for keyboard drag/drop and keyboard users can lose their place after a drop or cancel.
} = useSortable({ id });
src/components/CustomizableDashboard/CustomizableDashboard.tsx:102
- These internal droppable ids share the same namespace as the public
PortletItem.idvalues. A valid portlet whose id iscolumn-0,column-1, orcolumn-2would register a duplicate dnd-kit id and be misclassified as a column by theCOL_IDS.indexOfchecks, breaking drag/reorder behavior. Namespace the internal ids or explicitly validate/document those ids as reserved.
const COL_IDS = ['column-0', 'column-1', 'column-2'] as const;
src/components/CustomizableDashboard/CustomizableDashboard.tsx:462
- When
columnsremoves an item, this effect only updates React state; it never writes the reconcilednextorder back to{storageKey}-portlet-order. The stale id remains persisted and regains its old position if the item is later re-added, contrary to the documented removal/new-item behavior. Persist the normalized order after reconciliation (outside the state updater).
setColOrder((prev) => {
const next = mergeColumnOrder(columns, controlledOrder ?? prev);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…k, SSR restore, validated persistence - Cross-column drops keep the drag-over position instead of flipping past the hovered item (crossed-column guard in reorderOnDrop) - Auto-shrink uses highest occupied column, so a gap like [a],[],[c] no longer hides column 3 - Persisted layout/order restore moved to a mount effect so the first client render matches SSR (no hydration mismatch) - Saved order slots validated as string arrays; corrupted storage falls back cleanly - Persistence/callbacks moved out of setState updaters (Strict Mode replay safety); drag math extracted to exported pure helpers with direct unit tests
Deploying ui with
|
| Latest commit: |
a23d157
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://6ddbc07d.ui-6d0.pages.dev |
| Branch Preview URL: | https://feat-customizable-dashboard.ui-6d0.pages.dev |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (8)
Previously missed (2) — in code that hasn't changed since the last review.
src/components/CustomizableDashboard/CustomizableDashboard.tsx:680
- Selecting
layout={1}orlayout={2}on the initial render (or receiving it from a controlled parent) does not runhandleLayoutChange, socolOrdercan still contain items in the hidden columns.visibleColsthen drops those arrays and the portlets disappear; theTwoColumnLayoutstory withordersin column 3 reproduces this. Normalize/consolidate the order whenever the active layout is initialized or changes externally before deriving the visible columns.
const visibleCols =
layoutMode === 1 ? [cols[0]] : layoutMode === 2 ? [cols[0], cols[1]] : cols;
src/components/CustomizableDashboard/CustomizableDashboard.tsx:240
COL_IDSshare the same string namespace as the publicPortletItem.id. A valid item with an id such ascolumn-0is interpreted as the column droppable when it is theoverId, so hovering or dropping on that item can append to the wrong column. Namespace internal droppable ids or carry target-type metadata instead of inferring it from a string.
const colIdx = COL_IDS.indexOf(overId as (typeof COL_IDS)[number]);
const sourceCol = findColumn(order, activeId);
if (sourceCol === -1) return order;
const targetCol = colIdx !== -1 ? colIdx : findColumn(order, overId);
src/components/CustomizableDashboard/CustomizableDashboard.tsx:700
- These controls expose an ARIA radiogroup/radio pattern but only implement click handling. Unlike native radios,
role="radio"buttons do not get arrow-key behavior automatically; all three remain in the tab order and arrow keys do nothing. Add roving tabindex plus Arrow/Home/End handling, or use native radio semantics.
<button
key={mode}
type="button"
className={cn(
src/components/CustomizableDashboard/CustomizableDashboard.tsx:373
findHeader()runs beforeobserver.observe(), so when the header is present on first render itsobserver.disconnect()is immediately undone by thisobservecall. The observer then remains registered until a later child mutation or unmount, despite the disconnect-on-discovery behavior described above. Start observing before the initialfindHeader()call.
findHeader();
observer.observe(container, { childList: true, subtree: true });
src/components/CustomizableDashboard/CustomizableDashboard.tsx:567
- This reconciliation can remove stale portlet ids from a trailing column when
columnsor a controlledorderchanges, but it never recomputesrequiredColumnsor updates the layout. A dashboard that removes its last-column widget therefore remains in 3-column mode with an empty trailing column and does not fireonLayoutChange; apply the same auto-shrink normalization used at drop commit to this path as well.
setColOrder((prev) => {
const next = mergeColumnOrder(columns, controlledOrder ?? prev);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
src/components/CustomizableDashboard/CustomizableDashboard.tsx:335
- The drag listeners and keyboard attributes are attached to a portaled handle, but the
setActivatorNodeRefreturned byuseSortableis not wired to it. dnd-kit uses that activator ref for keyboard-drag focus management; when a cross-column move remounts the portlet, keyboard users can lose focus after dropping. DestructuresetActivatorNodeRefhere and pass it as the handle button's ref.
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
src/components/CustomizableDashboard/CustomizableDashboard.tsx:360
- Disconnecting after the first match assumes that the matched header node and its
classattribute never change. If a portlet later updates aDashboardWidgetprop that changesCardHeader.className, React removes the imperatively added layout classes; if the header is replaced,headerElpoints at a detached portal target. The grip can then be misplaced or disappear. Keep discovery active for relevant child/attribute changes or reapply/manage the classes for the current header.
observer.disconnect();
src/components/CustomizableDashboard/CustomizableDashboard.tsx:464
- This visible empty-column affordance is hard-coded in English, as are the drag and layout labels below. Host applications cannot localize these strings, which makes the new component unusable in a translated UI. Expose localized label props or a translation callback and use it for the visible text, button labels, titles, and default region/column labels.
Drop here
…der after crossing, dashboard safelist - reorderOnDrop now only preserves the exact insertion adjacency after a cross-column move; hovering any other item in the destination column still reorders, so a drag keeps moving after it crosses - Responsive grid, max-lg:contents flatten, drag-handle, and drop- placeholder utilities added to both Tailwind 3 safelist twins
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (11)
Previously missed (4) — in code that hasn't changed since the last review.
src/components/CustomizableDashboard/CustomizableDashboard.tsx:102
- The public
PortletItem.idis an arbitrary string, butcolumn-0throughcolumn-2are also used as droppable IDs and are special-cased by the ordering helpers. A portlet using one of those IDs will collide in dnd-kit's registry and be treated as a column target, so it cannot be reliably dragged or reordered. Namespace the internal column IDs or explicitly validate/reject these reserved IDs.
const COL_IDS = ['column-0', 'column-1', 'column-2'] as const;
src/components/CustomizableDashboard/CustomizableDashboard.tsx:568
- If
columnschanges while a drag is active, this effect returns and never runs again afterdraggingRefflips back to false because that ref is not a dependency. Newly added portlets therefore never entercolOrder(and are not rendered), while removed portlets can remain in the order that is committed/persisted. Reconcile once the drag ends/cancels, while preserving the in-progress order during the drag.
React.useEffect(() => {
if (draggingRef.current) return;
src/components/CustomizableDashboard/CustomizableDashboard.tsx:340
- Because the listeners/attributes are attached to a separate handle rather than the sortable wrapper, the handle also needs dnd-kit's
setActivatorNodeRef. Without registering the activator node, keyboard drags cannot reliably restore focus to the handle after drop/cancel (and the sensor has no activator ref for its accessibility behavior). DestructuresetActivatorNodeReffromuseSortableand assign it to this button.
} = useSortable({ id });
src/components/CustomizableDashboard/CustomizableDashboard.tsx:560
- When
storageKeychanges, this effect only overwrites state when the new key has a saved value. If the next dashboard has no saved order/layout, the previous dashboard'scolOrderandinternalLayoutremain active, leaking one dashboard/user's arrangement into another instead of falling back to the new props/defaults. Reset uncontrolled state to the new props/defaults before applying any values loaded for the new key.
React.useEffect(() => {
if (!storageKey) return;
if (controlledLayout === undefined) {
const savedLayout = loadLayout(storageKey);
if (savedLayout !== null) setInternalLayout(savedLayout);
}
if (controlledOrder === undefined) {
const savedOrder = loadOrder(storageKey);
if (savedOrder) {
setColOrder(mergeColumnOrder(columnsRef.current, savedOrder));
src/components/CustomizableDashboard/CustomizableDashboard.tsx:396
- Every handle is exposed as the same accessible name (
Drag to reorder), so a screen-reader user cannot tell which portlet the focused control will move when several portlets are present. Include a per-portlet name (preferably the portlet title via an accessible label/relationship) in the handle's accessible name.
aria-label="Drag to reorder"
src/components/CustomizableDashboard/CustomizableDashboard.tsx:365
- The observer is disconnected permanently after the first matching header is found. If a portlet replaces or removes that header later (for example, swapping a loading/error view for the real widget),
headerElstill points at the old detached element and the handle is portaled out of the visible DOM, with no fallback or re-discovery. Keep the target validated/re-observe after content mutations, or rescan when the portlet content changes.
header.classList.add(...HEADER_LAYOUT_CLASSES);
setHeaderEl(header);
observer.disconnect();
src/components/CustomizableDashboard/CustomizableDashboard.tsx:270
- After a drag crosses into a non-empty column by hovering an item,
moveAcrossColumnsinserts the active item before that item. If the pointer then moves into the column gap,over.idbecomescolumn-N; the cross-column helper now no-ops because source and target are the same, andreorderOnDropimmediately returns for every column target. Dropping there therefore leaves the earlier insertion instead of appending to the destination, unlike a direct drop into the column.
COL_IDS.indexOf(overId as (typeof COL_IDS)[number]) !== -1;
if (isColumnTarget || overId === activeId) return order;
src/components/CustomizableDashboard/CustomizableDashboard.tsx:317
closestCenterproduces a nearest collision whenever any measured droppable exists, so this guard is effectively always true for a mounted dashboard. Pointer drags outside all columns therefore never reachrectIntersection;overbecomes the nearest portlet/column andhandleDragEndcommits a drop instead of taking its no-overcancellation path. RestrictclosestCenterto keyboard drags (or otherwise let pointer drags fall through torectIntersection).
const cc = closestCenter(args);
if (cc.length > 0) return cc;
return rectIntersection(args);
src/components/CustomizableDashboard/CustomizableDashboard.tsx:714
- These controls expose
role="radio"but leave every button at the default tab stop and provide no Arrow/Home/End handling. Keyboard users must tab through all three radios and cannot use standard radiogroup navigation;src/components/CodeLookup/CodeLookup.tsx:698-700implements rovingtabIndexand a key handler for the same pattern. Add roving focus and keyboard navigation (including RTL direction) before relying on this ARIA role.
role="radio"
aria-checked={layoutMode === mode}
aria-label={`${mode} column layout`}
src/tailwind-preset.cjs:150
- The active drop state uses both
border-primary-500andtext-primary-600inCustomizableDashboard.tsx:466, but neither utility is present in this CommonJS safelist; the additions here only coverbg-primary-500/10. Tailwind 3 consumers using this preset can purge the border and text styling for an active drop target. Add both utilities here (and keep the TypeScript preset in sync).
'bg-primary-500/10',
src/tailwind-preset.ts:624
- The active drop state uses
border-primary-500inCustomizableDashboard.tsx:466, but this safelist does not include that utility (only the relatedbg-primary-500/10is added here). Tailwind 3 consumers that import the component from the package can therefore lose the primary border after purging, making the drop target indistinguishable from the inactive placeholder. Add the missing utility to this safelist.
'bg-primary-500/10',
| const visibleCols = | ||
| layoutMode === 1 ? [cols[0]] : layoutMode === 2 ? [cols[0], cols[1]] : cols; |
| const findHeader = () => { | ||
| const header = container.querySelector(handleSelector); | ||
| if (header instanceof HTMLElement) { | ||
| header.classList.add(...HEADER_LAYOUT_CLASSES); | ||
| setHeaderEl(header); | ||
| observer.disconnect(); | ||
| } else { | ||
| setHeaderEl(null); | ||
| } | ||
| setSearched(true); | ||
| }; | ||
|
|
||
| const observer = new MutationObserver(() => { | ||
| cancelAnimationFrame(rafId); | ||
| rafId = requestAnimationFrame(findHeader); | ||
| }); | ||
|
|
||
| findHeader(); | ||
| observer.observe(container, { childList: true, subtree: true }); |
Extracts echart-sim's proven portlet-dashboard shell into the design system as
CustomizableDashboard— the first piece of the customizable-dashboard system (the add/remove widget panel follows in a stacked PR).What it does
lg/mdstorageKey(derives the exact{key}-portlet-order/{key}-dashboard-layoutkeys echart-sim already writes, so saved layouts survive adoption), or controlledorder/onOrderChange+layout/onLayoutChangefor server-side stores (e.g. waggleline'sUserLayoutscollection).onOrderChangefires only at commit points, never per drag-over event.dragHandleSelector(default: theDashboardWidgetheader slot); portlets with no matching header get a floating handle in the top end cornertoolbarSlotportal so host pages can hoist the layout toggle into their own header actions rowmergeColumnOrderexported for host-side migrations (saved positions win, stale ids dropped, new items append to their props column)Screenshots
Dependency note
Adds
@dnd-kit/core+@dnd-kit/sortable+@dnd-kit/utilitiesas regular dependencies — both current consumers of this pattern (echart-sim, waggleline) already ship @dnd-kit, and per-component tsup entries tree-shake it away for apps that don't import this component. The existinguseDragReorderHTML5 hook remains the lightweight flat-list option; this component needs cross-column moves and keyboard sorting.Provenance
Port of
echart-sim/app/src/components/layout/Dashboard.tsx(Home, Patient Summary, and Reports pages), generalized: SCSS → Tailwind--mieweb-*tokens, RTL-safe logical properties, first-class header contract, controlled-persistence props. echart-sim can delete its bespoke shell once this ships (adoption PR planned).Testing
mergeColumnOrderreconciliation, layout toggle + consolidation, localStorage persistence/restore, controlled props, a11y roles)toolbarSlottypecheck/lint/format/rtl:scanclean; full suite 596/596MAINTAINERS.mddocuments the dnd-kit coupling, header-portal mechanics, and known tradeoffs