Skip to content

feat(CustomizableDashboard): draggable multi-column portlet dashboard - #389

Open
ebellamy-bh wants to merge 3 commits into
mainfrom
feat/customizable-dashboard
Open

feat(CustomizableDashboard): draggable multi-column portlet dashboard#389
ebellamy-bh wants to merge 3 commits into
mainfrom
feat/customizable-dashboard

Conversation

@ebellamy-bh

Copy link
Copy Markdown

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

  • Drag-and-drop between and within columns via @dnd-kit (pointer + keyboard sensors — handles carry dnd-kit's arrow-key sorting instructions for screen readers)
  • 1/2/3-column layout toggle with auto-shrink when trailing columns empty, and responsive collapse (3→2→1) at lg/md
  • Persistence: localStorage via storageKey (derives the exact {key}-portlet-order / {key}-dashboard-layout keys echart-sim already writes, so saved layouts survive adoption), or controlled order/onOrderChange + layout/onLayoutChange for server-side stores (e.g. waggleline's UserLayouts collection). onOrderChange fires only at commit points, never per drag-over event.
  • Drag-handle header contract: each portlet's grip portals into the element matching dragHandleSelector (default: the DashboardWidget header slot); portlets with no matching header get a floating handle in the top end corner
  • toolbarSlot portal so host pages can hoist the layout toggle into their own header actions row
  • mergeColumnOrder exported for host-side migrations (saved positions win, stale ids dropped, new items append to their props column)

Screenshots

Light Dark
CustomizableDashboard light CustomizableDashboard dark

Dependency note

Adds @dnd-kit/core + @dnd-kit/sortable + @dnd-kit/utilities as 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 existing useDragReorder HTML5 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

  • 13 unit tests (mergeColumnOrder reconciliation, layout toggle + consolidation, localStorage persistence/restore, controlled props, a11y roles)
  • 5 Storybook stories incl. controlled mode and toolbarSlot
  • typecheck / lint / format / rtl:scan clean; full suite 596/596
  • MAINTAINERS.md documents the dnd-kit coupling, header-portal mechanics, and known tradeoffs

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • layout and order are documented as independently controllable/mixable, but this render path simply omits later logical columns for layout 1/2. Thus layout={1} with order={[['a'], ['b'], ['c']]} (or persisted data from a different layout) renders only a and silently drops b/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

  • commitOrder performs localStorage writes, invokes consumer callbacks, and may call setLayout from inside a functional setColOrder updater. 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 setNodeRef is attached only to the wrapper. Attach the setActivatorNodeRef returned by useSortable to 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.id values. A valid portlet whose id is column-0, column-1, or column-2 would register a duplicate dnd-kit id and be misclassified as a column by the COL_IDS.indexOf checks, 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 columns removes an item, this effect only updates React state; it never writes the reconciled next order 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.

Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx Outdated
Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx Outdated
Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx Outdated
Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx Outdated
Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx
Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx
…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
Copilot AI review requested due to automatic review settings August 23, 2026 04:17
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying ui with  Cloudflare Pages  Cloudflare Pages

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

View logs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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} or layout={2} on the initial render (or receiving it from a controlled parent) does not run handleLayoutChange, so colOrder can still contain items in the hidden columns. visibleCols then drops those arrays and the portlets disappear; the TwoColumnLayout story with orders in 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_IDS share the same string namespace as the public PortletItem.id. A valid item with an id such as column-0 is interpreted as the column droppable when it is the overId, 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 before observer.observe(), so when the header is present on first render its observer.disconnect() is immediately undone by this observe call. The observer then remains registered until a later child mutation or unmount, despite the disconnect-on-discovery behavior described above. Start observing before the initial findHeader() 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 columns or a controlled order changes, but it never recomputes requiredColumns or 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 fire onLayoutChange; 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 setActivatorNodeRef returned by useSortable is 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. Destructure setActivatorNodeRef here 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 class attribute never change. If a portlet later updates a DashboardWidget prop that changes CardHeader.className, React removes the imperatively added layout classes; if the header is replaced, headerEl points 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

Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx
Comment thread src/components/CustomizableDashboard/CustomizableDashboard.tsx
…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
Copilot AI review requested due to automatic review settings August 23, 2026 04:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.id is an arbitrary string, but column-0 through column-2 are 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 columns changes while a drag is active, this effect returns and never runs again after draggingRef flips back to false because that ref is not a dependency. Newly added portlets therefore never enter colOrder (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). Destructure setActivatorNodeRef from useSortable and assign it to this button.
  } = useSortable({ id });

src/components/CustomizableDashboard/CustomizableDashboard.tsx:560

  • When storageKey changes, 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's colOrder and internalLayout remain 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), headerEl still 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, moveAcrossColumns inserts the active item before that item. If the pointer then moves into the column gap, over.id becomes column-N; the cross-column helper now no-ops because source and target are the same, and reorderOnDrop immediately 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

  • closestCenter produces 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 reach rectIntersection; over becomes the nearest portlet/column and handleDragEnd commits a drop instead of taking its no-over cancellation path. Restrict closestCenter to keyboard drags (or otherwise let pointer drags fall through to rectIntersection).
  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-700 implements roving tabIndex and 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-500 and text-primary-600 in CustomizableDashboard.tsx:466, but neither utility is present in this CommonJS safelist; the additions here only cover bg-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-500 in CustomizableDashboard.tsx:466, but this safelist does not include that utility (only the related bg-primary-500/10 is 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',

Comment on lines +684 to +685
const visibleCols =
layoutMode === 1 ? [cols[0]] : layoutMode === 2 ? [cols[0], cols[1]] : cols;
Comment on lines +360 to +378
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 });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants