diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index 7d15ab0..87d6b7f 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -396,6 +396,31 @@ export class StringManager { }; } + /** + * Get accessibility string properties + */ + public getA11y(): { + videoPlayerStringProperty: ReadOnlyProperty; + videoScrubberStringProperty: ReadOnlyProperty; + rewindToStartStringProperty: ReadOnlyProperty; + digitizingAreaStringProperty: ReadOnlyProperty; + digitizeTrackStringProperty: ReadOnlyProperty; + removeTrackStringProperty: ReadOnlyProperty; + dataTableStringProperty: ReadOnlyProperty; + exportCSVStringProperty: ReadOnlyProperty; + } { + return { + videoPlayerStringProperty: this.stringProperties.a11y.videoPlayerStringProperty, + videoScrubberStringProperty: this.stringProperties.a11y.videoScrubberStringProperty, + rewindToStartStringProperty: this.stringProperties.a11y.rewindToStartStringProperty, + digitizingAreaStringProperty: this.stringProperties.a11y.digitizingAreaStringProperty, + digitizeTrackStringProperty: this.stringProperties.a11y.digitizeTrackStringProperty, + removeTrackStringProperty: this.stringProperties.a11y.removeTrackStringProperty, + dataTableStringProperty: this.stringProperties.a11y.dataTableStringProperty, + exportCSVStringProperty: this.stringProperties.a11y.exportCSVStringProperty, + }; + } + /** * Get video file label string properties */ diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index caa5209..622bec5 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -137,5 +137,15 @@ "pendulumDrag": "Pendulum Drag", "pucksCollide": "Pucks Collide", "springWars": "Spring Wars" + }, + "a11y": { + "videoPlayer": "Video player", + "videoScrubber": "Video timeline — drag to seek", + "rewindToStart": "Rewind to start", + "digitizingArea": "Video digitizing area — click to record the position of the active track", + "digitizeTrack": "Digitize track {{symbol}}", + "removeTrack": "Remove track {{symbol}}", + "dataTable": "Track data", + "exportCSV": "Export track data as CSV file" } } diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 0155353..8bb4aba 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -137,5 +137,15 @@ "pendulumDrag": "Pendule avec frottement", "pucksCollide": "Rondelles en collision", "springWars": "Guerres de ressorts" + }, + "a11y": { + "videoPlayer": "Lecteur vid\u00e9o", + "videoScrubber": "Ligne de temps vid\u00e9o \u2014 glisser pour naviguer", + "rewindToStart": "Revenir au d\u00e9but", + "digitizingArea": "Zone de num\u00e9risation vid\u00e9o \u2014 cliquer pour enregistrer la position de la piste active", + "digitizeTrack": "Num\u00e9riser la piste {{symbol}}", + "removeTrack": "Supprimer la piste {{symbol}}", + "dataTable": "Donn\u00e9es de piste", + "exportCSV": "Exporter les donn\u00e9es de piste en fichier CSV" } } diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index f3c537f..5348ff0 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -21,6 +21,13 @@ import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN, PANEL_CORNER_RADIUS } from "../../Tra import type { SimModel } from "../model/SimModel.js"; import type { Track } from "../model/Track.js"; +// ── Accessibility ───────────────────────────────────────────────────────────── +// The HTML table gets a element for screen readers. The caption text +// is supplied by the caller so it can be localized. +type A11yLabels = { + tableCaption: string; +}; + // ── Grid geometry ──────────────────────────────────────────────────────────── const MAX_TABLE_WIDTH = 600; // Allow table to grow wider for more tracks const MIN_TABLE_HEIGHT = 100; // Minimum height when little data @@ -142,6 +149,7 @@ function buildHtmlTable( unit: string, colors: TableColors, labels: TableLabels, + a11y: A11yLabels, ): HTMLDivElement { const dataRows = buildDataRows(tracks); @@ -164,6 +172,12 @@ function buildHtmlTable( white-space: nowrap; `; + // ── Accessible caption (visually hidden but read by screen readers) ──────── + const caption = document.createElement("caption"); + caption.textContent = a11y.tableCaption; + caption.style.cssText = "position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;"; + table.appendChild(caption); + // ── Header row ───────────────────────────────────────────────────────────── const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); @@ -180,12 +194,17 @@ function buildHtmlTable( z-index: 1; `; - const addHeaderCell = (content: string | HTMLElement) => { + const addHeaderCell = (content: string | HTMLElement, fullLabel?: string) => { const th = document.createElement("th"); + th.scope = "col"; if (typeof content === "string") { th.textContent = content; } else { th.appendChild(content); + // Provide a plain-text aria-label when the header content is HTML (colored symbol) + if (fullLabel) { + th.setAttribute("aria-label", fullLabel); + } } th.style.cssText = headerStyle; headerRow.appendChild(th); @@ -224,8 +243,8 @@ function buildHtmlTable( addHeaderCell(`y (${unit})`); } else { for (const track of tracks) { - addHeaderCell(makeTrackHeader("x", track)); - addHeaderCell(makeTrackHeader("y", track)); + addHeaderCell(makeTrackHeader("x", track), `${track.symbol} x (${unit})`); + addHeaderCell(makeTrackHeader("y", track), `${track.symbol} y (${unit})`); } } @@ -364,6 +383,7 @@ export class DataTableNode extends Panel { unitProperty: TReadOnlyProperty, ) { const dataTableStrings = StringManager.getInstance().getDataTable(); + const a11yStrings = StringManager.getInstance().getA11y(); const getLabels = (): TableLabels => ({ frame: dataTableStrings.frameStringProperty.value, @@ -371,6 +391,10 @@ export class DataTableNode extends Panel { noData: dataTableStrings.noDataStringProperty.value, }); + const getA11yLabels = (): A11yLabels => ({ + tableCaption: a11yStrings.dataTableStringProperty.value, + }); + // Helper to get current table colors from properties const getTableColors = (): TableColors => ({ headerBg: TrackLabColors.tableHeaderBackgroundProperty.value.toCSS(), @@ -384,7 +408,7 @@ export class DataTableNode extends Panel { }); // ── Create scrollable HTML table ───────────────────────────────────────── - const tableWrapper = buildHtmlTable([], "m", getTableColors(), getLabels()); + const tableWrapper = buildHtmlTable([], "m", getTableColors(), getLabels(), getA11yLabels()); const tableDomNode = new DOM(tableWrapper, { allowInput: true }); // ── Export button ──────────────────────────────────────────────────────── @@ -402,6 +426,7 @@ export class DataTableNode extends Panel { ], spacing: EXPORT_BUTTON_ICON_SPACING, }), + accessibleName: a11yStrings.exportCSVStringProperty, baseColor: TrackLabColors.exportButtonProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, xMargin: BUTTON_X_MARGIN, @@ -458,7 +483,7 @@ export class DataTableNode extends Panel { // Replaces the entire table DOM and refreshes cached references. const doFullRebuild = (tracks: readonly Track[], unit: string) => { const colors = getTableColors(); - const newWrapper = buildHtmlTable(tracks, unit, colors, getLabels()); + const newWrapper = buildHtmlTable(tracks, unit, colors, getLabels(), getA11yLabels()); this.tableWrapper.innerHTML = ""; if (newWrapper.firstChild) { diff --git a/src/screen-name/view/DigitizingOverlayNode.ts b/src/screen-name/view/DigitizingOverlayNode.ts index 681a4c9..f4aad51 100644 --- a/src/screen-name/view/DigitizingOverlayNode.ts +++ b/src/screen-name/view/DigitizingOverlayNode.ts @@ -9,6 +9,7 @@ import { type Dimension2, Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; import { DOM, FireListener, Node, Path, Rectangle } from "scenerystack/scenery"; import { Tandem } from "scenerystack/tandem"; +import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; @@ -214,10 +215,14 @@ export class DigitizingOverlayNode extends Node { magCtx.stroke(); }; + const a11yStrings = StringManager.getInstance().getA11y(); + const digitizingOverlay = new Rectangle(0, 0, VIDEO_WIDTH, VIDEO_HEIGHT, { fill: "transparent", cursor: "none", visible: false, + tagName: "div", + accessibleName: a11yStrings.digitizingAreaStringProperty, }); digitizingOverlay.addChild(cursorNode); digitizingOverlay.addChild(magnifierNode); diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index 56c7bb0..0fd7551 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -13,6 +13,7 @@ import { ButtonNode, RectangularPushButton, Slider } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; +const a11yStrings = StringManager.getInstance().getA11y(); import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; @@ -108,6 +109,7 @@ export class PlaybackControlsNode extends HBox { this.isScrubbing = false; }, enabledProperty: model.videoLoadedProperty, + accessibleName: a11yStrings.videoScrubberStringProperty, }); const onTimeChange = (time: number) => { @@ -177,6 +179,7 @@ export class PlaybackControlsNode extends HBox { }, enabledProperty: model.videoLoadedProperty, tandem: Tandem.OPT_OUT, + accessibleName: a11yStrings.rewindToStartStringProperty, }); this.children = [timeControlNode, scrubber, rewindButton, infoDisplay]; diff --git a/src/screen-name/view/TrackListPanel.ts b/src/screen-name/view/TrackListPanel.ts index b5f8641..a5d9524 100644 --- a/src/screen-name/view/TrackListPanel.ts +++ b/src/screen-name/view/TrackListPanel.ts @@ -21,6 +21,7 @@ import { ButtonNode, Checkbox, Panel, RectangularPushButton } from "scenerystack import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; +const a11yStrings = StringManager.getInstance().getA11y(); import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN, PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; import type { Track } from "../model/Track.js"; @@ -140,6 +141,7 @@ class TrackRowNode extends Node { const checkbox = new Checkbox(isDigitizingProperty, new Rectangle(0, 0, 0, 0), { boxWidth: CHECKBOX_BOX_WIDTH, tandem: Tandem.OPT_OUT, + accessibleName: a11yStrings.digitizeTrackStringProperty.value.replace("{{symbol}}", track.symbol), }); checkbox.left = CHECKBOX_X; checkbox.centerY = ROW_CY; @@ -153,6 +155,7 @@ class TrackRowNode extends Node { yMargin: BUTTON_Y_MARGIN, listener: () => model.removeTrack(track.id), tandem: Tandem.OPT_OUT, + accessibleName: a11yStrings.removeTrackStringProperty.value.replace("{{symbol}}", track.symbol), }); trashButton.centerY = ROW_CY; trashButton.right = PANEL_WIDTH - TRASH_BUTTON_RIGHT_OFFSET; diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index 4d99c7a..978bc82 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -14,6 +14,7 @@ import type { SimModel } from "../model/SimModel.js"; const MAIN_CONTENT_SPACING = 10; // VBox gap between source control, video layer, and playback +import { StringManager } from "../../i18n/StringManager.js"; import { AutoTrackerNode } from "./AutoTrackerNode.js"; import { DigitizingOverlayNode } from "./DigitizingOverlayNode.js"; import { PlaybackControlsNode } from "./PlaybackControlsNode.js"; @@ -45,6 +46,8 @@ export class VideoPlayerNode extends Node { super(); this.model = model; + const a11yStrings = StringManager.getInstance().getA11y(); + // ── HTML video element ───────────────────────────────────────────────── this.videoElement = document.createElement("video"); this.videoElement.width = VIDEO_WIDTH; @@ -52,6 +55,7 @@ export class VideoPlayerNode extends Node { this.videoElement.preload = "metadata"; this.videoElement.crossOrigin = "anonymous"; this.videoElement.style.display = "block"; + this.videoElement.setAttribute("aria-label", a11yStrings.videoPlayerStringProperty.value); const videoBackgroundListener = (c: import("scenerystack").Color) => { this.videoElement.style.background = c.toCSS(); };