From b6080e4271e2c911e03c326ae03ed14fe1a038cf Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:43:59 +0200 Subject: [PATCH 1/3] docs(datagrid-web): add OpenSpec artifacts for aria-live footer and checkbox labels Co-Authored-By: Claude Sonnet 4.5 --- .../datagrid-aria-live-footer/.openspec.yaml | 2 + .../datagrid-aria-live-footer/design.md | 143 ++++++++++++++++++ .../datagrid-aria-live-footer/proposal.md | 29 ++++ .../specs/select-all-aria-label/spec.md | 101 +++++++++++++ .../specs/selection-aria-live/spec.md | 115 ++++++++++++++ .../datagrid-aria-live-footer/tasks.md | 105 +++++++++++++ 6 files changed, 495 insertions(+) create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/.openspec.yaml create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/design.md create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/proposal.md create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/specs/select-all-aria-label/spec.md create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/specs/selection-aria-live/spec.md create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/tasks.md diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/.openspec.yaml b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/.openspec.yaml new file mode 100644 index 0000000000..fab62b414b --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-24 diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/design.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/design.md new file mode 100644 index 0000000000..d408a99495 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-aria-live-footer/design.md @@ -0,0 +1,143 @@ +## Context + +The DataGrid widget currently lacks screen reader feedback for row selection changes and the select-all checkbox lacks a descriptive label. When users select or deselect rows, there is no announcement to assistive technology users. Additionally, the select-all checkbox in the header has no accessible name, forcing screen readers to announce it generically as "checkbox" without context. + +The selection counter component exists but is purely visual. It renders in the footer when enabled, but screen readers have no programmatic way to detect selection state changes without manually navigating to the counter. + +The DataGrid uses a dependency injection system (brandi) with tokens defined in `src/model/tokens.ts` and features injected via hooks. The `WidgetFooter` component renders pagination, load-more buttons, and the selection counter in a flex layout. The select-all checkbox is rendered in the header, likely in a component that handles column headers or selection controls. + +## Goals / Non-Goals + +**Goals:** +- Add status region (`role="status"`) that announces selection count changes to screen readers per WCAG 4.1.3 +- Place the status region in the footer, separate from the visual counter, so announcements work even when the counter is hidden +- Add `aria-label` to the select-all checkbox in the header to provide context to screen reader users +- Extract reusable selection logic from `select-all` module to `widget-plugin-grid` for use by both select-all and the new status component +- Ensure announcements are concise, atomic (complete messages), and don't spam screen readers + +**Non-Goals:** +- Modifying the existing visual selection counter UI or checkbox appearance +- Adding aria-live announcements for other grid events (sorting, filtering, pagination) +- Changing the selection behavior or API +- Adding dynamic aria-label that changes based on selection state (keep it simple) + +## Decisions + +### 1. Place aria-live region in WidgetFooter + +**Decision:** Render the `SelectionAriaLive` component in `WidgetFooter.tsx`, outside the conditional `` that controls the visual counter visibility. + +**Why:** The aria-live region must always be present in the DOM when selection is enabled, even if the visual counter is hidden. Screen readers ignore DOM mutations to aria-live regions that are added/removed dynamically. By placing it in the footer unconditionally, we ensure announcements work regardless of the `selectionCounterPosition` prop. + +**Alternatives considered:** +- Render inside `SelectionCounter` component → rejected because the counter is conditionally rendered based on position (top/bottom/off) +- Render in widget root → rejected because it would be far from the visual counter semantically, and the footer already controls selection-related UI + +### 2. Extract selection model to widget-plugin-grid + +**Decision:** Create `packages/shared/widget-plugin-grid/src/core/models/selection.model.ts` with factories for `selectedCount`, `isAllItemsSelected`, and `isCurrentPageSelected` computed atoms. Also extract the `selectionStatus` text logic that handles the "all items selected" case. + +**Why:** The select-all module already computes these values in `select-all.model.ts`, but they are tightly coupled to the select-all feature. The status region needs the same selection state AND the same text logic without depending on select-all (which is optional). Extracting to a shared model allows both features to reuse the same logic. + +**Critical:** The status region must use the same text logic as `selectAllTextsStore.selectionStatus`, which returns: +- `allSelectedText` when `isAllItemsSelected` is true ("All 100 rows selected.") +- `selectedCountText` otherwise ("50 items selected") + +Without this, the status region would announce "100 items selected" when the visual SelectAllBar shows "All 100 rows selected", causing a mismatch. + +**Alternatives considered:** +- Duplicate logic in selection-counter feature → rejected to avoid divergence and maintenance burden +- Make aria-live depend on select-all → rejected because select-all is an optional feature +- Only use `selectedCountText` → rejected, misses "all items selected" case + +### 3. Use role="status" for announcements + +**Decision:** Use `role="status"` rather than explicit `aria-live="polite"`. + +**Why:** Per WCAG 4.1.3 (Status Messages), selection count changes qualify as status messages (providing information on action results without causing a change of context). The `role="status"` is semantically correct and implicitly provides `aria-live="polite"` and `aria-atomic="true"`, ensuring: +- Screen readers announce the entire message ("3 items selected") not just the number +- Announcements are polite (non-interrupting) +- The semantic role conveys intent more clearly than just aria-live + +**Alternatives considered:** +- `aria-live="polite"` alone → less semantic, requires explicit `aria-atomic="true"` +- `role="alert"` → rejected, reserved for urgent warnings/errors per WCAG guidance + +### 4. Only announce when selection count changes + +**Decision:** The aria-live region updates only when `selectedCount` changes, using MobX reactivity. + +**Why:** We avoid redundant announcements (e.g., when the grid re-renders for other reasons). The computed atom for `selectedCount` ensures the announcement text only changes when the actual count changes. + +### 5. Status region uses selectionStatus logic, not just selectedCountText + +**Decision:** The status region must read from `selectionStatus` (which handles the "all items selected" case), not directly from `selectedCountText`. + +**Why:** This prevents the mismatch bug found in code review: if the status region only reads `selectedCountText`, it would announce "100 items selected" even when `isAllItemsSelected` is true and the visual SelectAllBar shows "All 100 rows selected." The `selectionStatus` logic correctly returns: +- `allSelectedText` when all items across pages are selected +- `selectedCountText` otherwise (partial selection) + +**Implementation:** Extract the `selectionStatus` computed property from `selectAllTextsStore` into the shared selection model so both the SelectAllBar and the status region use the same logic. + +**Alternatives considered:** +- Read only `selectedCountText` → rejected, causes announcement/visual mismatch +- Duplicate the logic in status component → rejected, violates DRY and risks divergence + +### 6. Add static aria-label to select-all checkbox + +**Decision:** Add a static `aria-label="Select all rows"` to the select-all checkbox, without dynamic updates based on selection state. + +**Why:** The checkbox already conveys its checked state through the native `checked` attribute, which screen readers announce automatically. The aria-label only needs to identify the purpose of the checkbox. Adding dynamic text (e.g., "Deselect all rows") would be redundant since screen readers already say "checked" or "not checked." + +**Alternatives considered:** +- Dynamic aria-label that changes between "Select all" and "Deselect all" → rejected as redundant with native checkbox state +- Make the text configurable via widget XML property → deferred for now (can add later if localization is needed) + +### 7. Ensure all selection controls are keyboard accessible + +**Decision:** Verify that all selection controls are fully keyboard accessible with proper focus management and keyboard interaction patterns. + +**Why:** Per WCAG 2.1.1 (Keyboard), all functionality must be operable through keyboard. There are three selection controls that must be keyboard accessible: +1. **Select-all checkbox** (header) - selects current page rows +2. **"Select all rows" button** (SelectAllBar) - selects across all pages +3. **"Clear selection" button** (SelectAllBar) - clears all selections + +**Requirements for checkbox:** +- Checkbox is in the tab order (not `tabindex="-1"` unless part of roving tabindex pattern) +- Space key toggles the checkbox state +- Enter key also works for activation (browser default) +- Focus indicator is clearly visible +- Works with grid keyboard navigation (if applicable) + +**Requirements for SelectAllBar buttons:** +- Buttons are native ` - ) : ( - - )} + ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts index 1d86ba4f7c..261c1f27a0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts @@ -13,14 +13,7 @@ import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../../model/tok import { SelectAllBarViewModel } from "./SelectAllBar.viewModel"; import { SelectionProgressDialogViewModel } from "./SelectionProgressDialog.viewModel"; -injected( - selectAllTextsStore, - SA_TOKENS.gate, - CORE.selection.selectedCount, - CORE.selection.selectedCounterTextsStore, - CORE.atoms.totalCount, - CORE.selection.isAllItemsSelected -); +injected(selectAllTextsStore, SA_TOKENS.gate, CORE.atoms.totalCount, CORE.selection.selectionStatusStore); injected( SelectAllBarViewModel, diff --git a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionCounter.tsx b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionCounter.tsx index 978025e56d..abe870330a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionCounter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionCounter.tsx @@ -1,18 +1,26 @@ import { observer } from "mobx-react-lite"; +import { useRef } from "react"; import { useSelectActions } from "../../model/hooks/injection-hooks"; +import { returnFocusToGrid } from "../../utils/focus-return"; import { useSelectionCounterViewModel } from "./injection-hooks"; export const SelectionCounter = observer(function SelectionCounter() { const selectionCountStore = useSelectionCounterViewModel(); const selectActions = useSelectActions(); + const counterRef = useRef(null); + + const handleClear = (): void => { + selectActions.clearSelection(); + returnFocusToGrid(counterRef.current); + }; return ( -
+
{selectionCountStore.selectedCountText}  |  -
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/__tests__/SelectionStatus.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/__tests__/SelectionStatus.spec.tsx new file mode 100644 index 0000000000..8e1e0d6c40 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/__tests__/SelectionStatus.spec.tsx @@ -0,0 +1,75 @@ +import { act, render, screen } from "@testing-library/react"; +import { observable, runInAction } from "mobx"; +import { SelectionStatus, SelectionStatusViewModel } from "../SelectionStatus"; + +function createViewModel(overrides: Partial = {}): SelectionStatusViewModel { + return { + selectionStatus: "3 items selected", + isVisible: true, + ...overrides + }; +} + +describe("SelectionStatus", () => { + it("renders status region with role='status' when visible", () => { + render(); + + const status = screen.getByRole("status"); + expect(status).toBeInTheDocument(); + }); + + it("renders selection status text", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("5 items selected"); + }); + + it("renders nothing when not visible", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("applies sr-only class for visual hiding", () => { + render(); + + expect(screen.getByRole("status")).toHaveClass("sr-only"); + }); + + it("shows 'All X rows selected' text when all items selected", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("All 100 rows selected."); + }); + + it("shows empty text when no items selected", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent(""); + }); + + it("updates text reactively when selection count changes", () => { + const vm = observable({ + selectionStatus: "1 item selected", + isVisible: true + }); + + render(); + expect(screen.getByRole("status")).toHaveTextContent("1 item selected"); + + act(() => { + runInAction(() => { + vm.selectionStatus = "3 items selected"; + }); + }); + expect(screen.getByRole("status")).toHaveTextContent("3 items selected"); + }); + + it("status text matches SelectAllBar text format for all selected", () => { + const allSelectedText = "All 100 rows selected."; + render(); + + expect(screen.getByRole("status")).toHaveTextContent(allSelectedText); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/focus-return.ts b/packages/pluggableWidgets/datagrid-web/src/utils/focus-return.ts new file mode 100644 index 0000000000..19f838cc44 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/utils/focus-return.ts @@ -0,0 +1,24 @@ +/** + * Returns focus to the select-all checkbox after selection is cleared. + * Falls back to the grid's active cell (roving tabindex) if no checkbox exists. + */ +export function returnFocusToGrid(container: Element | null | undefined): void { + if (!container) { + return; + } + const widget = container.closest(".widget-datagrid"); + const grid = widget?.querySelector('[role="grid"]') ?? container.closest('[role="grid"]'); + if (!grid) { + return; + } + const checkbox = grid.querySelector(".widget-datagrid-col-select input"); + if (checkbox) { + checkbox.focus(); + return; + } + // Fall back to the grid's active cell managed by the roving tabindex pattern. + const activeCell = grid.querySelector( + '[role="gridcell"][tabindex="0"], [role="columnheader"][tabindex="0"]' + ); + activeCell?.focus(); +} diff --git a/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts index f2d2adea55..f3a601a63b 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts @@ -41,14 +41,11 @@ export interface ObservableSelectAllTexts { /** @injectable */ export function selectAllTextsStore( gate: DerivedPropsGate<{ - allSelectedText?: DynamicValue; selectAllTemplate?: DynamicValue; selectAllText?: DynamicValue; }>, - selectedCount: ComputedAtom, - selectedTexts: { selectedCountText: string }, totalCount: ComputedAtom, - isAllItemsSelected: ComputedAtom + selectionStatus: { selectionStatus: string } ): ObservableSelectAllTexts { return observable({ get selectAllLabel() { @@ -59,13 +56,7 @@ export function selectAllTextsStore( return selectAllText; }, get selectionStatus() { - if (isAllItemsSelected.get()) return this.allSelectedText; - return selectedTexts.selectedCountText; - }, - get allSelectedText() { - const str = gate.props.allSelectedText?.value ?? "All %d rows selected."; - const count = selectedCount.get(); - return str.replace("%d", `${count}`); + return selectionStatus.selectionStatus; } }); } diff --git a/packages/shared/widget-plugin-grid/src/selection-counter/SelectionStatus.viewModel.ts b/packages/shared/widget-plugin-grid/src/selection-counter/SelectionStatus.viewModel.ts index e1d72c4f43..796a242ccb 100644 --- a/packages/shared/widget-plugin-grid/src/selection-counter/SelectionStatus.viewModel.ts +++ b/packages/shared/widget-plugin-grid/src/selection-counter/SelectionStatus.viewModel.ts @@ -1,4 +1,3 @@ -import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; import { makeAutoObservable } from "mobx"; /**