Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/i18n/StringManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,31 @@ export class StringManager {
};
}

/**
* Get accessibility string properties
*/
public getA11y(): {
videoPlayerStringProperty: ReadOnlyProperty<string>;
videoScrubberStringProperty: ReadOnlyProperty<string>;
rewindToStartStringProperty: ReadOnlyProperty<string>;
digitizingAreaStringProperty: ReadOnlyProperty<string>;
digitizeTrackStringProperty: ReadOnlyProperty<string>;
removeTrackStringProperty: ReadOnlyProperty<string>;
dataTableStringProperty: ReadOnlyProperty<string>;
exportCSVStringProperty: ReadOnlyProperty<string>;
} {
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
*/
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/strings_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
10 changes: 10 additions & 0 deletions src/i18n/strings_fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
35 changes: 30 additions & 5 deletions src/screen-name/view/DataTableNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <caption> 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
Expand Down Expand Up @@ -142,6 +149,7 @@ function buildHtmlTable(
unit: string,
colors: TableColors,
labels: TableLabels,
a11y: A11yLabels,
): HTMLDivElement {
const dataRows = buildDataRows(tracks);

Expand All @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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})`);
}
}

Expand Down Expand Up @@ -364,13 +383,18 @@ export class DataTableNode extends Panel {
unitProperty: TReadOnlyProperty<string>,
) {
const dataTableStrings = StringManager.getInstance().getDataTable();
const a11yStrings = StringManager.getInstance().getA11y();

const getLabels = (): TableLabels => ({
frame: dataTableStrings.frameStringProperty.value,
timeSeconds: dataTableStrings.timeSecondsStringProperty.value,
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(),
Expand All @@ -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 ────────────────────────────────────────────────────────
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/screen-name/view/DigitizingOverlayNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/screen-name/view/PlaybackControlsNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -108,6 +109,7 @@ export class PlaybackControlsNode extends HBox {
this.isScrubbing = false;
},
enabledProperty: model.videoLoadedProperty,
accessibleName: a11yStrings.videoScrubberStringProperty,
});

const onTimeChange = (time: number) => {
Expand Down Expand Up @@ -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];
Expand Down
3 changes: 3 additions & 0 deletions src/screen-name/view/TrackListPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/screen-name/view/VideoPlayerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,13 +46,16 @@ 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;
this.videoElement.height = VIDEO_HEIGHT;
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();
};
Expand Down