From 9d955843da616384286a71978d24c27ca4e29aa6 Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Wed, 9 Sep 2026 12:11:57 +0200 Subject: [PATCH 1/4] feat(Svg): forward role and aria-label to the svg element An svg with no accessible name is announced shape by shape, and browsers exporting tagged PDF give every primitive its own structure element, so a chart arrives as dozens of anonymous figures instead of one described graphic. role defaults to img when ariaLabel is set. A name without a role is unreliable on a bare svg, and a role without a name is worse than neither: it hides whatever text the drawing contains and puts nothing in its place. Both default to undefined, so nothing changes for existing users. --- packages/cx/src/svg/Svg.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/cx/src/svg/Svg.tsx b/packages/cx/src/svg/Svg.tsx index 6de6dd1e0..ae59af588 100644 --- a/packages/cx/src/svg/Svg.tsx +++ b/packages/cx/src/svg/Svg.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource react */ +import { StringProp } from "../ui/Prop"; import { RenderingContext } from "../ui/RenderingContext"; import { ResizeManager } from "../ui/ResizeManager"; import { VDOM, Widget } from "../ui/Widget"; @@ -18,6 +19,15 @@ interface SvgInstance extends BoundedObjectInstance { } export interface SvgConfig extends BoundedObjectConfig { + /** ARIA role for the `svg` element. Defaults to `img` when `ariaLabel` is set. */ + role?: StringProp; + + /** + * Accessible name for the `svg` element. Setting it announces the drawing as a single graphic + * instead of as its individual shapes, which is also how a tagged PDF export tags it. + */ + ariaLabel?: StringProp; + /** Set to `true` to automatically calculate width based on the measured height and `aspectRatio`. */ autoWidth?: boolean; @@ -53,6 +63,16 @@ export class Svg extends BoundedObject { super(config); } + declareData(...args: any[]) { + return super.declareData( + { + role: undefined, + ariaLabel: undefined, + }, + ...args, + ); + } + initState(context: RenderingContext, instance: SvgInstance) { const size = { width: 0, @@ -147,6 +167,9 @@ class SvgComponent extends VDOM.Component { const { instance, data, size, children, eventHandlers } = this.props; const { widget } = instance; + // An unnamed drawing is left alone: `role="img"` would hide its text and put nothing in its place. + const role = data.role ?? (data.ariaLabel ? "img" : undefined); + const defs: any[] = []; for (const k in (instance as any).clipRects) { let cr = (instance as any).clipRects[k]; @@ -181,7 +204,7 @@ class SvgComponent extends VDOM.Component { {...eventHandlers} > {size.width > 0 && size.height > 0 && ( - + {defs} {children} From b1ac14b5880c65d39567bdc443e4a8bc2dc36313 Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Wed, 9 Sep 2026 12:12:09 +0200 Subject: [PATCH 2/4] feat(Legend): hide unnamed entry shapes, allow naming them A legend shape is a colour key whose meaning is already carried by the entry text beside it, so it is now hidden from assistive technology rather than announced as an unnamed graphic. shapeRole and shapeAriaLabel forward onto each shape's svg for callers that need it exposed, deriving role the same way Svg does. The two are mutually exclusive by construction: aria-hidden removes the element from the accessibility tree, which is also what a tagged PDF is built from, so a named shape must not carry it. --- packages/cx/src/charts/Legend.tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cx/src/charts/Legend.tsx b/packages/cx/src/charts/Legend.tsx index 6eecec463..aeee88401 100644 --- a/packages/cx/src/charts/Legend.tsx +++ b/packages/cx/src/charts/Legend.tsx @@ -41,6 +41,15 @@ export interface LegendConfig extends HtmlElementConfig { /** Default shape to use for all entries. */ shape?: StringProp; + /** ARIA role for each entry's shape `svg`. Defaults to `img` when `shapeAriaLabel` is set. */ + shapeRole?: StringProp; + + /** + * Accessible name for each entry's shape `svg`. Unnamed shapes are hidden from assistive + * technology, since the entry text already says what they mean. + */ + shapeAriaLabel?: StringProp; + /** Style applied to each entry. */ entryStyle?: StyleProp; @@ -97,6 +106,8 @@ export class Legend extends HtmlElement { declareData(...args: any[]): void { super.declareData(...args, { shape: undefined, + shapeRole: undefined, + shapeAriaLabel: undefined, entryStyle: { structured: true }, entryClass: { structured: true }, valueStyle: { structured: true }, @@ -124,6 +135,8 @@ export class Legend extends HtmlElement { case "shapeSize": case "svgSize": case "shape": + case "shapeRole": + case "shapeAriaLabel": case "entryStyle": case "entryClass": case "valueStyle": @@ -177,7 +190,7 @@ export class Legend extends HtmlElement { instance.legends[this.name] && instance.legends[this.name].entries, list: React.ReactNode; - let { entryClass, entryStyle, shape, valueClass, valueStyle } = + let { entryClass, entryStyle, shape, shapeRole, shapeAriaLabel, valueClass, valueStyle } = instance.data; let valueFormatter = Format.parse(this.valueFormat); @@ -210,7 +223,7 @@ export class Legend extends HtmlElement { onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} > - {this.renderShape(e, shape)} + {this.renderShape(e, shape, shapeRole, shapeAriaLabel)}
{e.displayText || e.name}
{this.showValues && (
@@ -229,6 +242,8 @@ export class Legend extends HtmlElement { renderShape( entry: LegendEntryData, legendEntriesShape: string | null | undefined, + role?: string, + ariaLabel?: string, ): React.ReactNode { const className = this.CSS.element(this.baseClass, "shape", { [`color-${entry.colorIndex}`]: @@ -244,6 +259,11 @@ export class Legend extends HtmlElement { return ( Date: Wed, 9 Sep 2026 12:12:09 +0200 Subject: [PATCH 3/4] fix(TextArea): implement trim instead of omitting it trim was excluded from TextAreaConfig and forced to false, so a textarea silently ignored it and a global TextField.prototype.trim never applied. It is applied to the commit that ends the edit rather than to intermediate input commits: trimming on every keystroke would strip whitespace the user is still typing, and in a multi-line field a trailing newline could never be entered at all. Whitespace inside the text is untouched; the visible text is synced so the field and the store agree. --- packages/cx/src/widgets/form/TextArea.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/cx/src/widgets/form/TextArea.tsx b/packages/cx/src/widgets/form/TextArea.tsx index f2feb525a..fb2298158 100644 --- a/packages/cx/src/widgets/form/TextArea.tsx +++ b/packages/cx/src/widgets/form/TextArea.tsx @@ -16,13 +16,14 @@ import { stopPropagation } from "../../util/eventCallbacks"; import { KeyCode } from "../../util/KeyCode"; import { autoFocus } from "../autoFocus"; import { getActiveElement } from "../../util/getActiveElement"; +import { isString } from "../../util/isString"; import { NumberProp } from "../../ui/Prop"; /** - * `trim` is intentionally omitted: `TextArea` renders its own input which commits the raw value, - * so leading and trailing whitespace is always preserved. + * `trim` applies to the commit that ends the edit, not to intermediate `input` commits. Whitespace + * inside the text is never touched. */ -export interface TextAreaConfig extends Omit { +export interface TextAreaConfig extends TextFieldConfig { /** Specifies the number of visible lines. */ rows?: NumberProp; @@ -69,8 +70,6 @@ export class TextArea extends TextField { TextArea.prototype.baseClass = "textarea"; TextArea.prototype.reactOn = "blur"; -// `trim` is not supported by TextArea, so a global `TextField.prototype.trim = true` must not leak into it. -TextArea.prototype.trim = false; TextArea.prototype.suppressErrorsUntilVisited = true; interface InputProps { @@ -205,8 +204,12 @@ class Input extends VDOM.Component { } if (instance.widget.reactOn.indexOf(change) != -1) { - let value = inputValue || widget.emptyValue; + // Trimming on `input` would strip whitespace the user is still typing, so a trailing + // newline could never be entered. + let text = change != "input" && data.trim && isString(inputValue) ? inputValue.trim() : inputValue; + let value = text || widget.emptyValue; instance.set("value", value); + if (this.input && text != inputValue) this.input.value = text; } } From 4b850406a1f2ee0cdbddff28a4f01098f7954528 Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Wed, 9 Sep 2026 12:21:32 +0200 Subject: [PATCH 4/4] tidy up --- .gitignore | 1 + .vscode/settings.json | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8aca6d7b3..684e62b5e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules *.hot-update.js *.log dist +dist-vite build Thumbs.db diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e2a0fe4e..5aad0eda6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,12 +28,21 @@ "statusBar.background": "#017ce6", "statusBar.foreground": "#e7e7e7", "statusBarItem.hoverBackground": "#1c95fe", - "statusBarItem.remoteBackground": "#017ce6", + "statusBarItem.remoteBackground": "#ffa0d3", "statusBarItem.remoteForeground": "#e7e7e7", "titleBar.activeBackground": "#017ce6", "titleBar.activeForeground": "#e7e7e7", "titleBar.inactiveBackground": "#017ce699", - "titleBar.inactiveForeground": "#e7e7e799" + "titleBar.inactiveForeground": "#e7e7e799", + "activityBarTop.activeBackground": "#1c95fe", + "activityBarTop.background": "#1c95fe", + "activityBarTop.foreground": "#e7e7e7", + "activityBarTop.inactiveForeground": "#e7e7e799", + "commandCenter.foreground": "#e7e7e7", + "statusBar.debuggingBackground": "#017ce6", + "statusBar.debuggingForeground": "#e7e7e7", + "activityBar.activeBorder": "#e7e7e7", + "activityBarTop.activeBorder": "#e7e7e7" }, "peacock.color": "#017ce6" }