Chart
A thin, accessibility-corrected wrapper over AG Charts Community. It resolves every mark colour from tokens in JS (a canvas cannot read CSS), honours prefers-reduced-motion and forced colors on the library’s behalf, and gives the chart a real accessible name instead of the generated “chart, N series”.
Preview
A chart ships with its data as a grid — see Text alternative below. The pairing is not decorative; it is what makes the chart conformant.
<esa-chart label="Rainfall by month"></esa-chart>
<!-- data and series are set as PROPERTIES, not attributes -->
<script>
chart.data = [{ month: 'Jan', rain: 20 }, /* … */];
chart.series = [{ type: 'bar', xKey: 'month', yKey: 'rain', yName: 'Rainfall (mm)' }];
</script> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-chart';
@Component({
selector: 'app-chart-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-chart label="Rainfall by month" [data]="data" [series]="series"></esa-chart>
<!-- data and series are set as PROPERTIES, not attributes -->
`,
})
export class ChartDemo {
data = [{ month: 'Jan', rain: 20 }, /* … */];
series = [{ type: 'bar', xKey: 'month', yKey: 'rain', yName: 'Rainfall (mm)' }];
} Multiple series
With more than one series a legend appears, and each series takes the next colour from the data-viz categorical family in fixed order — never cycled, never re-assigned when a filter changes the series count. Line and scatter series also take a rotating marker shape, so identity is never carried by hue alone.
chart.series = [
{ type: 'bar', xKey: 'month', yKey: 'rain', yName: 'Rainfall (mm)' },
{ type: 'line', xKey: 'month', yKey: 'temp', yName: 'Temperature (C)' },
]; API
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | '' | The chart's accessible name. Required — without it AG Charts announces a generated name like “chart, 2 series”, which names the shape of the thing rather than what it shows. |
data | unknown[] | [] | Row objects, passed to AG Charts untouched. |
series | Array<Record<string, unknown>> | [] | Series definitions. Marker shapes are layered in automatically so series differ by form as well as hue. |
options | Record<string, unknown> | {} | Escape hatch merged over everything the component computes — use it to reach any raw AG Charts option. |
color-scale | 'categorical' | 'sequential' | 'diverging' | 'categorical' | Which tier-2 data-viz family to read: categorical (identity), sequential (magnitude), or diverging (polarity). Property: `colorScale`. |
described-by | string | undefined | — | ID of prose describing the chart’s takeaway. Resolved in the light DOM and applied by element reflection, because an IDREF cannot cross a shadow boundary. Property: `describedBy`. |
height | string | '320px' | Plot height. The chart fills its container’s width. |
Installing
ag-charts-community is an optional peer dependency, loaded
by dynamic import on first render. A spoke that never charts never pays the ~2.8 MB,
and @esa/ecology’s own dependency list stays at lit alone.
npm install ag-charts-community Accessibility
AG Charts was scored in a real browser against the five questions worth asking of any charting library. It passes the two that are hardest to retrofit. The rest is what this wrapper exists to fix.
| Question | AG Charts alone | What esa-chart does |
|---|---|---|
| SVG or canvas? | Canvas — but with a synchronized proxy DOM of real elements carrying the semantics, and the canvas itself marked aria-hidden. | Nothing needed. This is why AG Charts is usable at all where most canvas libraries are not. |
| Default role and name? | Passes — role="figure" unprompted — but the generated name is the generic "chart, N series", and there is no option to change it. | Requires label and writes it onto the proxy root. Warns at runtime when absent rather than shipping the generic name silently. |
| Text or data-table alternative? | None. No table, no figcaption, no offscreen text. Values are reachable only by traversing the chart. | Not solved in the component — solved by pairing it with a grid. |
| Keyboard access to data points? | Passes, on by default. Arrows walk the data, Home/End jump to first and last, and focus is not trapped. | Nothing needed. This is the strongest of the five answers. |
Respects prefers-reduced-motion? | No. The string appears zero times in the library; every matchMedia call is for HiDPI scaling. | Reads the query itself and sets animation.enabled, re-deciding when the preference changes mid-session. |
Three more, found by measuring rather than reading the docs
- Legend hit targets render 16px tall — under the 24px raw floor, and
reported rather than patched. That is not a violation: SC 2.5.8’s spacing
exception passes an undersized target whose 24px circle clears its neighbours’, and
the default legend spacing earns it — axe reports zero target-size violations here.
Forcing it would mean 24px legend markers, since
marker.sizeis the only option that moves the box (item.paddingYis not an option at all; AG Charts accepts and ignores it). The exception is layout-dependent: a legend packed tight enough loses it, so ifnpm run a11yever reports target-size on a chart, the lever isoptions.legend.item.marker.size. - Forced colors cannot reach a canvas. It is a bitmap — no token, no
@mediablock and no assurance profile touches it. The only route in is to re-theme from JS with the system keywords, which this component does. It deliberately discards the data-viz palette in that mode: the user chose those colours, and overriding them would itself be SC 1.4.8. Marks stay apart by shape. - Series told apart by colour alone is SC 1.4.1, Level A. Line, area and scatter series get a rotating marker shape. Bars cannot carry a pattern in Community — for those the answer is direct labels or the paired grid.
What is still yours to do
- Give every chart a real
label. The component warns, but it cannot invent one. - Ship the paired grid. See below.
- Nothing automated proves a screen reader announces any of this correctly. Structure is provable; announcement is not. That needs NVDA/Firefox and VoiceOver/Safari.
Text alternative — pair it with a grid
AG Charts ships no data-table view, and the chart alone does not satisfy the
text-alternative requirement. The answer is a composition rather than a prop:
esa-grid already solves tabular semantics, and a visible
“view as data” gives sighted keyboard users a real affordance instead of an
offscreen table only a screen reader can find.
<esa-chart label="Rainfall by month" .data={rows} .series={series} />
<esa-collapsible summary="View as data">
<esa-grid columnDefs={cols} rowData={rows} />
</esa-collapsible> Theming — why a canvas needs a bridge
A grid re-themes for free because it is DOM. A chart does not: measured, overriding the
library’s own --ag-charts-* custom properties changes
getComputedStyle and leaves the canvas painting its original colours. Those
~60 properties style the DOM chrome — tooltip, legend, menus, focus ring
— and about a third of the theming surface is therefore ordinary CSS. Every
mark, though, is resolved in JS and handed over as a literal.
Which means a canvas does not repaint when the cascade changes. The component watches
data-theme, data-scheme and data-a11y-assurance on
<html> and re-resolves. Without that, switching theme leaves the chart
painted in the old brand with no error anywhere.
Theming surface
Every public token this component reads, extracted from its source at build time,
split by blast radius: the hooks that belong to this component,
then the shared and system tokens it consumes alongside everything else. The
Resolves to column walks each token's real lineage —
component → semantic → primitive → raw value — so you
can see exactly where re-pointing it lands. A spoke re-skins by overriding these in
its theme-<slug>.css; component internals are never edited. No
hook for what you need? File it with /request-lego.
Wired to this component
None. This component has no hooks of its own — it themes entirely through the shared tokens below, so every override here also moves its siblings.
Shared tokens it reads
Owned by a family or by the semantic layer. Still overridable — but scope the
override (esa-foo { --form-border-color: … }) unless you mean to move
everything that reads it. Reach says what else that is.
| Token | Reach | Resolves to |
|---|---|---|
--color-background-elevation-raised | semantic every component | #fcfcfc via --color-gray-1 |
--color-border-default | semantic every component | one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half.
*
* This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus |
--radius-md | semantic every component | 0.5rem via --radius-200 |
--spacing-300 | primitive every component | 0.75rem |