Widget Grid
ProA dashboard grid that lays out YOUR widgets — ordered, hideable, reorderable by keyboard, one arrangement per width, layout persisted — with eight ready compositions to start from and your own template inside any card.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvWidgetGridComponent } from '@styloviz-pro/display';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Your own widgets
ProYour own content inside a card
ProPick a composition
ProCustomizable, persisted layout
ProSync layout to your backend
ProA layout save that fails
ProWhen to use
- Composing a dashboard from mixed widgets: KPIs, charts, funnels, feeds.
- You want a ready, responsive grid instead of hand-laying CSS grid.
- For a single metric, a Stat Card alone is enough.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Item identity & owned state
- Restorable state
- hiddenWidgetIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Server writes
- Writes
- layoutChange
Each payload carries a
requestKey— send it with the request so a retry cannot apply the same change twice.- Rollback
- Call
event.rollback()when the save fails — the change is already on screen, and this is what puts it back. - While saving
- Bind
savingLayout— with two writes outstanding there is no single state to roll back to, so the action stays locked until the first one lands. - On failure
- Pass the failure to
layoutErrorto show it in place — without taking away what the user was working on.
Saved state
- Stored kinds
- sv-widget-grid/layout
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| tiles | readonly MetricTile[] | null | null | The widgets to lay out. Supply this and the grid is a grid of *your* widgets; leave it and it renders `variant()`'s demo dashboard. Every id must be unique and stable — it is what a saved layout stores, so an id derived from an array index rearranges the dashboard the next time the array does. Ids in a stored layout that name no widget here are ignored, so adding and removing widgets between releases is safe. Only `[customizable]` mode reads this; the eight variant dashboards are static compositions with their own content. |
| variant | WidgetGridVariant | 'saas-kpi' | Dashboard composition variant (saas-kpi, analytics, leaderboard…). |
| ariaLabel | string | 'Dashboard' | Accessible name for the dashboard region. |
| customizable | boolean | false | Enables an interactive, customizable KPI grid over the active variant's tiles: users can remove, restore and reorder widgets. Off by default so the static showcase variants render exactly as before. |
| persistKey | string | '' | When set, the customizable layout (order + hidden widgets) is persisted to `localStorage` under this key and restored on load. |
| savingLayout | boolean | false | True while a layout change is being written to the host's backend. Every customize control stops responding, so a second change cannot start on top of the first and leave `rollback()` holding a layout that predates it. |
| layoutError | SvDataError | string | null | null | The last layout change did not save. Reported above the grid; the tiles stay where the user put them, because that is what they are looking at and the component cannot know whether the host means to retry. |
| hiddenWidgetIds | ReadonlySet<string> (two-way) | new Set() | Widget ids the user has removed (two-way). A `model` because `persistKey` could only ever store the layout on *this* device: a host with a server-side dashboard layout had `(layoutChange)` to observe it and no way at all to put it back. Binding a value wins over whatever `localStorage` holds. |
| widgetOrder | readonly string[] (two-way) | [] | Widget ids in display order (two-way); ids missing from it follow in the variant's own order, so a partial list is valid. See `hiddenWidgetIds`. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| layoutChange | WidgetGridLayout | Emitted whenever the user changes the layout (remove / restore / reorder / reset), so a parent can sync it to its own backend. Fires regardless of `persistKey`; not emitted on initial load. Kept alongside the two-way `hiddenWidgetIds` / `widgetOrder` rather than replaced by them: this carries both halves of the layout in one atomic snapshot, which is the shape a host PUTs to its backend. The models emit separately (a reorder touches only one of them), so they answer "what is the order now", not "the layout changed". |
| hiddenWidgetIdsChange | ReadonlySet<string> | Emits the new `hiddenWidgetIds` whenever it changes — the output half of `[(hiddenWidgetIds)]`. |
| widgetOrderChange | readonly string[] | Emits the new `widgetOrder` whenever it changes — the output half of `[(widgetOrder)]`. |
Customization & theming
- `tiles` is the grid's real input: pass your own widgets and everything the customizable grid does — order, hide, restore, reset, one arrangement per width, versioned persistence, keyboard reorder, the announcements and the alternative summary table — runs over them. Leave it off and it falls back to `variant()`'s demo dashboard, which is what the eight showcase compositions are.
- Widget ids must be unique and stable. The id is what a saved layout stores, so an id derived from an array index rearranges the dashboard the next time the array does. Ids in a stored layout that name no widget are ignored, so adding and removing widgets between releases is safe.
- Project `<ng-template svWidgetBody="<id>" let-tile>` to fill a card with your own content — a chart, a table, a feed. `svWidgetBody="*"` catches every widget with no template of its own. The card's frame stays the grid's (label, move/remove controls, position announcement, `role="listitem"`), because a host that had to rebuild the chrome to get custom content would be rebuilding the accessible part.
- A projected body replaces the KPI card, not the widget: the alternative summary table still reports the tile's value, change and trend, so choosing a custom rendering never drops the widget from the one place a screen reader can compare the widgets against each other.
- Eight ready dashboard compositions (set via `variant`).
- Mix KPI, chart, funnel and feed widgets.
- Set `customizable` for an interactive KPI grid — remove/restore/reorder widgets; add `persistKey` to save the layout to `localStorage` as a versioned `sv-widget-grid/layout` envelope, so a blob from an older release is migrated where it can be and ignored where it cannot rather than restoring a dashboard that is subtly wrong. (Drag-resize is on the roadmap.)
- Listen to `(layoutChange)` to sync the layout (order + hidden widgets) to your own backend — it fires on every change regardless of `persistKey`.
- `(layoutChange)` carries `requestKey` and `rollback()`: the grid rearranges optimistically, so the payload is also the way back if the write fails. `rollback()` restores both order and hidden set, and unwrites the `persistKey` copy so a reload does not resurrect the failed layout.
- `savingLayout` locks every customize control while a change is being written; `layoutError` reports a failed save above the grid, full width, since the change could have been anywhere in it.
- Two-way `hiddenWidgetIds` and `widgetOrder` let a host seed the layout from its own backend. `localStorage` only ever seeds an empty layout, so a bound layout always wins.
- Reset appears as soon as the layout differs from the variant's own — after a reorder, not only after a removal.
- Responsive reflow across breakpoints; name the dashboard with `ariaLabel`.
- On touch screens the interactive tile controls (reorder / remove) are always visible instead of hover-only, so they stay usable without a pointer.
- One arrangement per width, not one arrangement stretched. Four tiles in a row and four tiles in a column are different arrangements of the same dashboard, so a customization is filed under the width it was made at — `base`, `sm` (640px) or `xl` (1280px), matching the three column counts the grid actually renders. Rearranging on a laptop no longer silently rearranges the phone.
- A width with no arrangement of its own inherits from the nearest WIDER one, then from the last arrangement made anywhere. A dashboard gets arranged on the big screen, so a phone opening it the first time should start from the user's own layout rather than from a variant default nobody chose.
- The saved blob only grows a `byBreakpoint` member once a second width is customized, so a layout stored by an earlier release still restores unchanged and the schema version does not move. A resize deliberately does NOT emit `(layoutChange)` — nothing the user did changed, and a host saving on resize would overwrite one width's arrangement with another's.
- Read `activeBreakpoint()` to see which width the grid is laid out at. It is read-only on purpose: it is a fact about the viewport, not a setting, and a bound value fighting the media queries would put the grid in an arrangement the CSS is not rendering.
- Reset clears every width, not just the current one — it is the only route back to the variant's own layout, and one that left the phone rearranged would not do what it is called.
Best practices
- Do: Lead with the most important KPIs in the top-left where eyes land first.
- Do: Keep widget density consistent so the grid reads as one dashboard.
- Do: Bind `savingLayout` whenever you persist `(layoutChange)` to a server. It locks the customize controls, so a second change cannot start while the first is in flight — which is also what keeps `rollback()` unambiguous.
- Do: Call `event.rollback()` when the save fails. The grid has already rearranged, and it holds the only copy of the previous layout.
- Do: Send `event.requestKey` with the request and ignore a key you have already applied, so a retried PUT cannot apply the same reorder twice.
- Don't: Don't leave a failed save silent. Without `layoutError` the user is looking at an arrangement that exists only in that tab, and will assume it was saved.
- Don't: Don't hold a `rollback()` handle past the next change — it is refused, with a dev-mode warning, because restoring that snapshot would discard the newer change too.
- Do: Persist the whole `(layoutChange)` payload, not just `order` and `hidden`. Per-width arrangements ride in `byBreakpoint`, so dropping it flattens the user's phone and desktop layouts back into one.
- Tip: Verify the responsive reflow at mobile widths, not just desktop.
Accessibility
- Row/column placement and span ARE the layout, so the grid publishes a metrics table — one row per visible tile with its value, change and a described trend — plus an overview saying how many of the total are shown and how many are down on the previous period. A hidden tile is absent from both, so the summary can never claim a figure that is not on screen.
- The change cell joins the number to its `changeLabel` ("+12% vs last month"). On screen the chip shows a bare "+12%" and the period it is measured against is a separate span beside it; out of that layout the two have to be one phrase or the number means nothing.
- The sparkline gets a described shape rather than being dropped, so the trend column says something a listener can use.
- Each composition is a labelled `region` (`ariaLabel`) with a logical reading order.
- In customizable mode, reorder and remove controls are keyboard-operable buttons named per widget AND per position — "Move Revenue later, position 2 of 8", not eight identical "Move widget later"s. The old shared name made the whole toolbar indistinguishable in a rotor list, and the position is how a listener knows the press did anything.
- Every customize gesture is announced through an `sr-only` `role="status"` live region: where the tile went, how many are shown after a removal, where a restored one landed. The controls were always real buttons, so the gesture always worked — a keyboard user simply had no way to tell that it had.
- Focus survives every gesture. A browser blurs a control the instant it is `disabled`, and removing a tile destroys the button that was pressed, so each gesture re-focuses an ordered fallback list: the same control if it still exists, else its sibling, else the grid. Before this, every single press threw focus to `<body>`.
- Focus survives the SAVE too, which is the case a host actually hits. `savingLayout` disables every control at the instant the gesture changes the layout, so there is nothing to land on yet — the target is held and honoured when the save releases the controls, however long the backend takes. And because a rejected save rolls back exactly the control the gesture aimed at (a removal aims at the restore chip; the rollback puts the tile back and the chip goes), each fallback list carries both sides, so the failure path lands on the widget too. Focus you moved elsewhere during the save is never pulled back.
- A removed widget is recoverable. The restore chips sit in a `role="group"` labelled "Hidden widgets", each named "Restore <widget>", and focus after a removal lands on that widget's own chip — so the way back is the next thing you meet, not something to go hunting for. A restored widget returns to its original slot rather than the end.
- With everything hidden, the empty state names the control to press instead of pointing "above", which is not a direction a screen reader has.
- While `savingLayout` is set, every customize control is `disabled` and carries `aria-busy="true"`, so a screen-reader user is told the layout is being written rather than finding dead buttons.
- A failed save is announced: `layoutError` renders as `role="alert"` above the grid, so it reaches assistive tech without stealing focus.
- Trend chips convey direction with text ("increased/decreased by"), not color alone; decorative charts/sparklines are hidden from assistive tech.
Feedback on Widget Grid
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.