Skip to content

Fix Dashboard focus and scrolling for flow-layout KPI tile groups #438

Description

@BorisTyshkevich

Summary

Dashboard navigation can open a Dashboard with an optional tile focus target:

openDashboard({
  dashboardId,
  mode: 'view' | 'edit',
  focus: { kind: 'tile', id: tileId },
});

For ordinary tiles and grafana-grid tiles this works: the tile is scrolled into view, receives programmatic focus, and receives the temporary navigation highlight.

For a KPI tile rendered by the flow layout, the navigation request focuses and scrolls a display: contents element. That element has no physical box, so the Dashboard reports success while the visible KPI remains off-screen and has no persistent focus indication.

This is a functional and accessibility regression in #425's completed navigation contract. Fix it before #426 starts using tile-focus navigation from the Dashboard tree.

Current implementation

src/ui/dashboard.ts resolves a tile focus target as follows:

function tileFocusTarget(tileId: string): HTMLElement | null {
  return flowKpiHosts.get(tileId) ?? tileEls.get(tileId)?.card ?? null;
}

applyNavigationFocus() then treats that one element as all three concepts:

node.scrollIntoView({ block: 'nearest' });
node.focus();
highlightNavigationTarget(node);

A flow KPI entry in flowKpiHosts is .dash-kpi-member:

const host = h('div', {
  class: 'dash-kpi-member',
  'data-tile': member.tileId,
  role: 'group',
  'aria-label': ts.title,
});

The stylesheet deliberately defines:

.dash-kpi-member { display: contents; }

This is required by the current KPI-band composition model. One KPI query may produce several .kpi-card elements, and the cards from consecutive KPI tiles participate directly in one wrapping .dash-kpi-stream. The host remains in the DOM for tile identity, accessibility grouping, drag wiring, and lifecycle ownership, but it generates no layout box.

Measured in Chromium against tests/e2e/dashboard-kpi-move.html, the host's getBoundingClientRect() is { x: 0, y: 0, width: 0, height: 0 }.

Consequences:

  • scrollIntoView() has no target geometry and does not bring the KPI into view;
  • focus() can make the host document.activeElement, but the host has no box on which to paint a focus indicator;
  • the navigation call is treated as successful, so no diagnostic explains the miss;
  • keyboard and assistive-technology users are left with focus on an element that has no visible focus location.

Existing partial fix

Current main already contains the CSS reach-through added with #433:

.dash-kpi-member.is-nav-target > * {
  box-shadow: var(--ring-nav);
}

This makes the temporary navigation highlight visible on the physical KPI cards. Keep this behaviour.

It does not fix scrolling or normal focus indication because JavaScript still scrolls and focuses the zero-box host as though it were a normal tile card.

Required design

Do not force one DOM element to serve as the semantic focus target, scroll geometry, and highlight owner.

Introduce an internal resolved-navigation target with separate roles. The exact name is implementation-defined; the following shape illustrates the required contract:

interface DashboardNavigationTarget {
  /** Receives tabindex=-1 and programmatic focus. */
  focusNode: HTMLElement;

  /** Has real browser geometry and receives scrollIntoView(). */
  scrollNode: HTMLElement;

  /** Owns the temporary is-nav-target class. */
  highlightNode: HTMLElement;
}

Resolution rules:

Target kind focusNode scrollNode highlightNode
Ordinary/grafana-grid tile tile card tile card tile card
Flow-layout KPI tile .dash-kpi-member host first rendered physical child card/state card .dash-kpi-member host
Curated filter filter field filter field filter field

This preserves the correct semantics for a flow KPI tile:

  • the host remains the focused tile-level group and already has role="group" plus the tile title as its accessible name;
  • the leading physical child provides deterministic, non-zero scroll geometry;
  • the host owns the temporary class, so the existing descendant selector highlights every card produced by that tile;
  • a multi-card KPI remains one tile semantically without changing the flattened wrapping layout.

Why the first physical child is the scroll anchor

A KPI query may render multiple cards. Because the host uses display: contents, those cards can wrap across lines and do not have one honest rectangular tile box. Creating a physical wrapper would change the current stream layout and grouping behaviour.

Use the first rendered child as the stable leading scroll anchor. This is sufficient to bring the start of the requested tile into view while the tile-level host remains the semantic focus owner and all of its children receive the navigation highlight.

Do not:

  • change .dash-kpi-member to a normal flex/grid box;
  • wrap each KPI tile's cards in a new physical layout container;
  • focus only an arbitrary KPI metric and thereby replace the tile-level accessible name with one field's label;
  • calculate and maintain an absolutely positioned synthetic union rectangle;
  • add a timeout to retry focus.

Focus indication

After the temporary navigation highlight clears, focus remains on .dash-kpi-member. A visible normal focus indicator must therefore be painted on its physical children while the host is focused.

Add a descendant focus rule consistent with the existing focus vocabulary, for example:

.dash-kpi-member:focus-visible > * {
  /* normal focus treatment, distinct from --ring-nav */
}

The exact property may follow the repository's existing focus convention, but these requirements are fixed:

  • focus must remain visible after .is-nav-target is removed;
  • the normal focus indication and temporary navigation highlight must be distinguishable;
  • the temporary highlight must remain additive rather than permanently replacing focus styling;
  • every physical child belonging to a multi-card KPI tile must show the tile-level navigation/focus state consistently;
  • no new persistent Tab stop is added: the host receives tabindex="-1" only for programmatic focus.

Do not move tabindex onto every .kpi-card; that would introduce extra stops and expose metric cards as separate navigated tiles.

Empty and transitional host handling

The presence of a .dash-kpi-member host proves the tile exists. A temporarily empty host must not be reported as:

That panel is no longer on this dashboard.

Normally renderKpiInto() supplies at least one child for every state:

  • loading/unfilled/error/zero-data -> .dash-kpi-state-card;
  • ready -> one or more .kpi-card elements.

Still guard the resolver against a host with no physical child during a render transition.

Required behaviour:

  1. do not classify an existing empty host as a missing tile;
  2. deliver tile focus only after the flow reconciliation has populated KPI content through the existing deterministic render-complete path;
  3. if the implementation extracts a resolver that can be called before content exists, return a pending/unready result or defer to the next existing render publication—do not use an arbitrary timer;
  4. stale generation, surface switch, workspace switch, sign-out, or a later Dashboard open must invalidate the pending focus exactly as current Add dashboard selection state and a full-size dashboard work surface #425 focus work does;
  5. show the existing missing-panel toast only when neither a flow host nor an ordinary tile card exists for the requested tile ID.

Behaviour by KPI state

The fix must work in both View and Edit modes for:

  • a ready KPI with one card;
  • a ready KPI query producing several cards;
  • loading state;
  • unfilled-filter state;
  • query/configuration error state;
  • zero-data state.

For state cards, the state card is the physical scroll anchor while the host remains the focus/highlight owner.

Accessibility contract

For a successfully focused flow KPI tile:

  • document.activeElement is the .dash-kpi-member[data-tile="…"] host;
  • the host has tabindex="-1";
  • the host retains role="group";
  • the host's accessible name is the Dashboard tile/query title, not merely the first metric label;
  • at least one physical child is visibly within the Dashboard scroll viewport after navigation;
  • a visible focus indication remains after the bounded navigation highlight is cleared;
  • normal Tab order is unchanged;
  • focusing a tile by saved-query ID remains invalid; only tile ID resolves.

Lifecycle and stale-state requirements

Preserve all #425 safeguards:

  • opening Dashboard B suppresses late focus work from Dashboard A;
  • returning to Query suppresses pending Dashboard focus;
  • switching workspace suppresses pending focus from the previous workspace;
  • sign-out suppresses pending focus and removes highlight listeners/timers;
  • a user interaction that occurs before delayed focus delivery prevents focus stealing;
  • re-rendering retires the prior highlight and does not retain detached KPI children;
  • no focus or scroll occurs against a detached node.

The new separation of focusNode, scrollNode, and highlightNode must not weaken these generation and teardown checks.

Suggested implementation steps

src/ui/dashboard.ts

  1. Replace the single-node tile/filter resolver contract with a resolved navigation-target contract.
  2. For ordinary tiles and filters, return the same node for all roles.
  3. For a flow KPI:
    • resolve the tile by flowKpiHosts.get(tileId);
    • retain the host as focusNode and highlightNode;
    • resolve the first physical HTMLElement child as scrollNode after renderKpiInto() has populated it;
    • keep resolution by tile ID, never query ID.
  4. Update applyNavigationFocus() to:
    • add tabindex="-1" to focusNode when absent;
    • call scrollNode.scrollIntoView({ block: 'nearest' });
    • call focusNode.focus();
    • apply and later clear the temporary class on highlightNode;
    • use the existing generation and user-interaction guards.
  5. Make highlightNavigationTarget() operate on the highlight owner without assuming it is also the active or scrolled element.
  6. Preserve teardown of the highlight timer and document listeners.

src/styles.css

  1. Keep .dash-kpi-member { display: contents; }.
  2. Keep the existing .dash-kpi-member.is-nav-target > * reach-through.
  3. Add persistent descendant focus styling for a focused host.
  4. Ensure the normal focus indication remains visible after the temporary class clears.
  5. Do not change KPI stream sizing, wrapping, drag geometry, card widths, or mobile layout.

Tests

Use the existing #425 navigation-focus suite and the real-browser KPI fixture rather than creating unrelated infrastructure.

Unit tests

Extend describe('renderDashboard — navigation focus (#425)') in tests/unit/dashboard.test.ts.

Required assertions:

  1. Flow KPI target separation

    • render a flow-layout KPI tile;
    • focus by tile ID;
    • assert document.activeElement is .dash-kpi-member;
    • assert the host receives tabindex="-1" and .is-nav-target;
    • assert the recorded scrollIntoView call targets the first physical child, not the host.
  2. Multi-card KPI

    • render a KPI query producing at least three cards;
    • assert all cards remain children of one .dash-kpi-member;
    • assert navigation does not add a physical wrapper or alter card count/order;
    • assert the host—not an individual metric card—is the active element.
  3. State-card target

    • cover at least one non-ready state;
    • assert the .dash-kpi-state-card is used as the scroll node while the host receives focus/highlight.
  4. No false missing diagnostic

    • exercise an existing flow host before/while its physical content is populated through an extracted resolver or deterministic publication seam;
    • assert the missing-panel toast is not shown merely because the host temporarily has no child.
  5. Missing ID remains non-destructive

    • an unknown tile ID opens the Dashboard, focuses nothing, and shows the existing diagnostic.
  6. Stale generation

    • the new split-node flow path remains suppressed when the renderer generation is stale.
  7. Highlight cleanup

    • clearing the temporary highlight removes it from the host and descendants without moving focus.

Happy DOM cannot prove geometry or actual scrolling; unit tests should assert node selection and lifecycle only.

Playwright regression test

Add real-browser coverage, preferably by extending the existing KPI movement fixture or adding a focused sibling fixture/spec.

The fixture must:

  • use a flow layout;
  • place the target KPI below the initial .dash-page viewport so scrolling is required;
  • include one KPI tile that produces multiple cards (the existing first fixture query already produces three);
  • expose a deterministic way to request focus after the initial render, without a timeout;
  • keep the Dashboard's actual .dash-page as the scroll container.

Required browser assertions:

  1. reset .dash-page.scrollTop to 0 and invoke tile navigation;
  2. .dash-page.scrollTop becomes greater than 0, or otherwise changes enough to bring the target into view;
  3. document.activeElement is the requested .dash-kpi-member host;
  4. the host's own rectangle may remain zero—this is expected and must not be used as the success condition;
  5. the first physical card/state-card has non-zero geometry and intersects the visible Dashboard viewport after navigation;
  6. a multi-card tile's physical children receive the temporary navigation styling;
  7. after the temporary highlight is cleared by the next user interaction, the host remains focused and a normal visible focus indication remains on its children;
  8. no unrelated KPI tile is marked or focused;
  9. ordinary tile focus remains unchanged.

Run this in Chromium at minimum because the bug depends on real layout geometry. It may run in the repository's full browser matrix where stable.

Regression boundaries

The change must not alter:

  • KPI stream flattening or wrapping;
  • multi-card KPI visual ordering;
  • KPI drag/reorder behaviour, including Command/Ctrl-drag of the complete member;
  • ordinary text selection inside KPI cards;
  • grafana-grid tile focus;
  • filter focus;
  • tile-ID versus query-ID resolution;
  • Dashboard execution, refresh, layout, persistence, or revisions;
  • View/Edit mode semantics;
  • mobile Dashboard layout;
  • the bounded navigation-highlight duration and interaction-based cleanup.

Acceptance criteria

  • Navigating to a flow-layout KPI scrolls a real physical child into the Dashboard viewport.
  • The tile-level .dash-kpi-member receives programmatic focus and retains its accessible group name.
  • A visible normal focus indication remains after the temporary navigation highlight clears.
  • Single-card, multi-card, loading/unfilled/error/zero-data KPI states are handled without changing layout semantics.
  • An existing but temporarily empty KPI host is not misreported as a missing panel.
  • Missing tile IDs still produce the existing non-destructive diagnostic.
  • Stale render/workspace/surface focus work remains suppressed.
  • Ordinary tile and curated-filter navigation are unchanged.
  • Unit coverage verifies target separation and lifecycle behaviour.
  • Playwright coverage proves real scrolling and visible focus in a browser.
  • KPI movement/reorder and wrapping tests remain green.

Verification

The implementation PR must report results for:

npm test
npx tsc --noEmit
npm run build
npx playwright test tests/e2e/dashboard-kpi-move.spec.js <new-or-extended-focus-spec>

Run the complete Playwright matrix when the local/CI environment supports it.

Related work

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions