diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index 12099d82ea..d3d8d84322 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -132,10 +132,6 @@ $root: ".widget-datagrid"; z-index: 1; } - &:focus:not(:focus-visible) { - outline: none; - } - &:focus-visible { outline: 1px solid var(--brand-primary, $brand-primary); } @@ -285,10 +281,6 @@ $root: ".widget-datagrid"; } } - & *:focus { - outline: 0; - } - .align-column-left { justify-content: flex-start; } @@ -576,6 +568,15 @@ $root: ".widget-datagrid"; &:focus-visible { background-color: var(--brand-primary-50, $color-default-lighter); } + + &:focus-visible { + outline: 1px solid var(--brand-primary, $brand-primary); + } + + &[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + } } :where(#{$root}-selection-counter) { diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelectionA11y.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelectionA11y.spec.js new file mode 100644 index 0000000000..473dd42f16 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelectionA11y.spec.js @@ -0,0 +1,315 @@ +import { expect, test } from "@mendix/run-e2e/fixtures"; +import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; + +test.describe("datagrid-web selection accessibility", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/p/select-all-a11y"); + await waitForMendixApp(page); + }); + + test("select-all checkbox has aria-label attribute", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await expect(checkbox).toBeVisible(); + }); + + test("select-all checkbox is focusable within grid keyboard navigation", async ({ page }) => { + const widget = page.locator(".mx-name-dataGrid21"); + const grid = widget.getByRole("grid"); + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + + // Tab into the grid + for (let i = 0; i < 20; i++) { + await page.keyboard.press("Tab"); + if (await grid.evaluate(el => el.contains(document.activeElement))) { + break; + } + } + + // Use arrow keys to navigate to select-all checkbox in header + await page.keyboard.press("ArrowUp"); + await expect(checkbox).toBeFocused(); + }); + + test("clicking select-all checkbox selects all rows on page", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await expect(checkbox).not.toBeChecked(); + + await checkbox.click(); + await expect(checkbox).toBeChecked(); + + await checkbox.click(); + await expect(checkbox).not.toBeChecked(); + }); + + test("row checkboxes have aria-label with row number", async ({ page }) => { + const row1 = page.getByRole("checkbox", { name: "Select row 1", exact: true }); + const row2 = page.getByRole("checkbox", { name: "Select row 2", exact: true }); + await expect(row1).toBeVisible(); + await expect(row2).toBeVisible(); + }); + + test("Select all rows button is reachable via Tab after selecting page", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const selectAllButton = page.getByRole("button", { name: /Select all/ }); + await expect(selectAllButton).toBeVisible(); + + // Tab until the Select all button receives focus + for (let i = 0; i < 20; i++) { + await page.keyboard.press("Tab"); + if (await selectAllButton.evaluate(el => el === document.activeElement)) { + break; + } + } + await expect(selectAllButton).toBeFocused(); + }); + + test("Enter key activates Select all rows button", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const selectAllButton = page.getByRole("button", { name: /Select all/ }); + await expect(selectAllButton).toBeVisible(); + + // Tab to the Select all button + for (let i = 0; i < 20; i++) { + await page.keyboard.press("Tab"); + if (await selectAllButton.evaluate(el => el === document.activeElement)) { + break; + } + } + await page.keyboard.press("Enter"); + + const clearButton = page.getByRole("button", { name: /Clear selection/ }).first(); + await expect(clearButton).toBeVisible(); + }); + + test("Clear selection button appears after selecting all rows", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const selectAllButton = page.getByRole("button", { name: /Select all/ }).first(); + await selectAllButton.click(); + + const clearButton = page.getByRole("button", { name: /Clear selection/ }).first(); + await expect(clearButton).toBeVisible(); + }); + + test("Clear selection button clears all selected rows", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const selectAllButton = page.getByRole("button", { name: /Select all/ }).first(); + await selectAllButton.click(); + + const clearButton = page.getByRole("button", { name: /Clear selection/ }).first(); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + + await expect(clearButton).not.toBeVisible(); + }); + + test("logical tab order: checkbox -> grid -> SelectAllBar buttons", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const selectAllButton = page.getByRole("button", { name: /Select all/ }); + await expect(selectAllButton).toBeVisible(); + + const checkboxRect = await checkbox.boundingBox(); + const barRect = await selectAllButton.boundingBox(); + expect(checkboxRect.y).toBeLessThan(barRect.y); + }); + + test("status region announces selection changes to screen readers", async ({ page }) => { + const widget = page.locator(".mx-name-dataGrid21"); + await widget.waitFor(); + + const row1 = page.getByRole("checkbox", { name: "Select row 1", exact: true }); + const row2 = page.getByRole("checkbox", { name: "Select row 2", exact: true }); + + await row1.click(); + await expect(widget).toMatchAriaSnapshot(` + - status: "1 row selected" + `); + + await row2.click(); + await expect(widget).toMatchAriaSnapshot(` + - status: "2 rows selected" + `); + + await row1.click(); + await expect(widget).toMatchAriaSnapshot(` + - status: "1 row selected" + `); + + await row2.click(); + await expect(widget).toMatchAriaSnapshot(` + - status "" + `); + }); + + // "Clear selection" button exists in both the top bar (inside grid) and footer; + // this test targets the top bar button only. + test("Select all button retains focus and changes label to Clear selection", async ({ page }) => { + const widget = page.locator(".mx-name-dataGrid21"); + const grid = widget.getByRole("grid"); + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const selectAllButton = grid.getByRole("button", { name: /Select all/ }); + await expect(selectAllButton).toHaveAttribute("aria-live", "assertive"); + await selectAllButton.click(); + + const clearButton = grid.getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible(); + await expect(clearButton).toBeFocused(); + }); + + // When "Clear selection" hides the bar, focus should return to the select-all + // checkbox (the trigger that caused the bar to appear) per WAI-ARIA APG guidance. + test("Clear selection returns focus to select-all checkbox", async ({ page }) => { + const widget = page.locator(".mx-name-dataGrid21"); + const grid = widget.getByRole("grid"); + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const selectAllButton = grid.getByRole("button", { name: /Select all/ }); + await selectAllButton.click(); + + const clearButton = grid.getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + + await expect(checkbox).toBeFocused(); + }); + + // Second grid (mx-name-dataGrid22) uses row-click selection without checkboxes. + // Focus should return to the grid's active cell after clearing. + test("No-checkbox grid: clear selection returns focus to active cell", async ({ page }) => { + const widget2 = page.locator(".mx-name-dataGrid22"); + const grid2 = widget2.locator("[role='grid']"); + await expect(grid2).toBeVisible(); + + const firstRow = grid2.locator("[role='row']").nth(1); + await firstRow.click(); + + const clearButton = widget2.getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + + const activeCell = grid2.locator("[role='gridcell'][tabindex='0'], [role='columnheader'][tabindex='0']"); + await expect(activeCell.first()).toBeFocused(); + }); + + test("No-checkbox grid: clear after multi-select returns focus to active cell", async ({ page }) => { + const widget2 = page.locator(".mx-name-dataGrid22"); + const grid2 = widget2.locator("[role='grid']"); + await expect(grid2).toBeVisible(); + + // Select multiple rows via Ctrl+Click + const row1 = grid2.locator("[role='row']").nth(1); + const row2 = grid2.locator("[role='row']").nth(2); + await row1.click(); + await row2.click({ modifiers: ["ControlOrMeta"] }); + + const clearButton = widget2.getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + + const activeCell = grid2.locator("[role='gridcell'][tabindex='0'], [role='columnheader'][tabindex='0']"); + await expect(activeCell.first()).toBeFocused(); + }); + + test("No-checkbox grid: focus does not fall to body after clear", async ({ page }) => { + const widget2 = page.locator(".mx-name-dataGrid22"); + const grid2 = widget2.locator("[role='grid']"); + await expect(grid2).toBeVisible(); + + const firstRow = grid2.locator("[role='row']").nth(1); + await firstRow.click(); + + const clearButton = widget2.getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + + await expect(page.locator("body")).not.toBeFocused(); + }); + + test("No-checkbox grid: select all pages then clear returns focus to active cell", async ({ page }) => { + const widget2 = page.locator(".mx-name-dataGrid22"); + const grid2 = widget2.locator("[role='grid']"); + await expect(grid2).toBeVisible(); + + // Wait for data rows to load + const rows = grid2.locator("[role='row']"); + await expect(rows).not.toHaveCount(1); + + // Select all rows on the page by clicking each one + const rowCount = await rows.count(); + for (let i = 1; i < rowCount; i++) { + await rows.nth(i).click(); + } + + // Select-all bar should appear + const selectAllBar = widget2.locator(".widget-datagrid-select-all-bar"); + await expect(selectAllBar).toBeVisible(); + + // Click "Select all" to select across all pages (async operation) + const selectAllButton = selectAllBar.getByRole("button", { name: /Select all/ }); + await selectAllButton.click(); + + // Wait for async select-all to complete — button label changes to "Clear selection" + const clearButton = selectAllBar.getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible({ timeout: 15000 }); + await clearButton.click(); + + const activeCell = grid2.locator("[role='gridcell'][tabindex='0'], [role='columnheader'][tabindex='0']"); + await expect(activeCell.first()).toBeFocused(); + }); + + test("No-checkbox grid: Ctrl+A selects all rows and shows select-all bar", async ({ page }) => { + const widget2 = page.locator(".mx-name-dataGrid22"); + const grid2 = widget2.locator("[role='grid']"); + await expect(grid2).toBeVisible(); + + // Click a row to focus the grid + const firstRow = grid2.locator("[role='row']").nth(1); + await firstRow.click(); + + // Ctrl+A to select all page rows + await page.keyboard.press("ControlOrMeta+a"); + + // Select-all bar should appear with "Select all" button + const selectAllBar = widget2.locator(".widget-datagrid-select-all-bar"); + await expect(selectAllBar).toBeVisible(); + const selectAllButton = selectAllBar.getByRole("button", { name: /Select all/ }); + await expect(selectAllButton).toBeVisible(); + + // Click "Select all" to select across all pages (async operation) + await selectAllButton.click(); + + // Wait for async operation — button label changes to "Clear selection" + const clearButton = selectAllBar.getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible({ timeout: 15000 }); + await clearButton.click(); + + const activeCell = grid2.locator("[role='gridcell'][tabindex='0'], [role='columnheader'][tabindex='0']"); + await expect(activeCell.first()).toBeFocused(); + }); + + // The footer also has a "Clear selection" button; focus should return to the + // select-all checkbox when it clears selection and disappears. + test("Footer clear selection returns focus to select-all checkbox", async ({ page }) => { + const checkbox = page.getByRole("checkbox", { name: "Select all rows" }); + await checkbox.click(); + + const widget = page.locator(".mx-name-dataGrid21"); + const clearButton = widget.locator(".widget-datagrid-footer").getByRole("button", { name: /Clear selection/ }); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + + await expect(checkbox).toBeFocused(); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/archive/2026-07-02-datagrid-aria-live-footer/.openspec.yaml b/packages/pluggableWidgets/datagrid-web/openspec/changes/archive/2026-07-02-datagrid-aria-live-footer/.openspec.yaml new file mode 100644 index 0000000000..fab62b414b --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/archive/2026-07-02-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/archive/2026-07-02-datagrid-aria-live-footer/design.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/archive/2026-07-02-datagrid-aria-live-footer/design.md new file mode 100644 index 0000000000..13da171b2d --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/archive/2026-07-02-datagrid-aria-live-footer/design.md @@ -0,0 +1,194 @@ +## 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/SelectionStatus.tsx b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionStatus.tsx new file mode 100644 index 0000000000..927ed057d7 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionStatus.tsx @@ -0,0 +1,33 @@ +import { observer } from "mobx-react-lite"; +import { ReactElement } from "react"; + +export interface SelectionStatusViewModel { + selectionStatus: string; + isVisible: boolean; +} + +/** + * SelectionStatus component renders an ARIA live region that announces + * selection state changes to screen readers. + * + * Uses role="status" (WCAG 4.1.3 Status Messages) which provides: + * - aria-live="polite" (non-interrupting announcements) + * - aria-atomic="true" (announces complete message) + * + * The region is visually hidden but accessible to assistive technology. + */ +export const SelectionStatus = observer(function SelectionStatus({ + viewModel +}: { + viewModel: SelectionStatusViewModel; +}): ReactElement | null { + if (!viewModel.isVisible) { + return null; + } + + return ( +
+ {viewModel.selectionStatus} +
+ ); +}); 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/features/selection-counter/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts index 519c6fe216..1aaa0af266 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts @@ -2,3 +2,4 @@ import { createInjectionHooks } from "brandi-react"; import { DG_TOKENS } from "../../model/tokens"; export const [useSelectionCounterViewModel] = createInjectionHooks(DG_TOKENS.selectionCounterVM); +export const [useSelectionStatusViewModel] = createInjectionHooks(DG_TOKENS.selectionStatusVM); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts index 2499558714..384c45688c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -25,6 +25,7 @@ import { PaginationViewModel } from "@mendix/widget-plugin-grid/pagination/main"; import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionCounter.viewModel-atoms"; +import { SelectionStatusViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionStatus.viewModel"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { MainGateProps } from "../../../typings/MainGateProps"; @@ -246,6 +247,7 @@ const _07_selectionBindings: BindingGroup = { CORE.selection.selectedCounterTextsStore, DG.selectionCounterCfg.optional ); + injected(SelectionStatusViewModel, CORE.selection.selectionStatusStore, DG.selectionType); }, define(container: Container) { container.bind(DG.selectionGate).toInstance(SelectionGate).inTransientScope(); @@ -254,6 +256,7 @@ const _07_selectionBindings: BindingGroup = { container.bind(DG.rowClass).toInstance(rowClassProvider).inTransientScope(); container.bind(DG.rowKey).toInstance(rowKeyProvider).inTransientScope(); container.bind(DG.selectionCounterVM).toInstance(SelectionCounterViewModel).inSingletonScope(); + container.bind(DG.selectionStatusVM).toInstance(SelectionStatusViewModel).inSingletonScope(); }, init(container, { config, props }) { container.bind(DG.selectionType).toConstant(config.selectionType); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts index d62d242228..ac2a1659e3 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts @@ -10,7 +10,8 @@ import { isAllItemsSelectedAtom, isCurrentPageSelectedAtom, selectedCountMultiAtom, - selectionCounterTextsStore + selectionCounterTextsStore, + selectionStatusStore } from "@mendix/widget-plugin-grid/core/models/selection.model"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { Container, injected } from "brandi"; @@ -44,6 +45,13 @@ injected( injected(isCurrentPageSelectedAtom, CORE.mainGate); injected(selectedCountMultiAtom, CORE.mainGate); injected(selectionCounterTextsStore, CORE.mainGate, CORE.selection.selectedCount); +injected( + selectionStatusStore, + CORE.mainGate, + CORE.selection.selectedCount, + CORE.selection.selectedCounterTextsStore, + CORE.selection.isAllItemsSelected +); injected(PageSizeStore, CORE.initPageSize.optional); // other @@ -80,6 +88,7 @@ export class RootContainer extends Container { this.bind(CORE.selection.isCurrentPageSelected).toInstance(isCurrentPageSelectedAtom).inTransientScope(); this.bind(CORE.selection.selectedCounterTextsStore).toInstance(selectionCounterTextsStore).inTransientScope(); this.bind(CORE.selection.isAllItemsSelected).toInstance(isAllItemsSelectedAtom).inTransientScope(); + this.bind(CORE.selection.selectionStatusStore).toInstance(selectionStatusStore).inTransientScope(); this.bind(CORE.texts).toInstance(TextsService).inTransientScope(); // paging diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index e2036b7388..a0d45f65e4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -81,7 +81,10 @@ export const CORE_TOKENS = { selectedCounterTextsStore: token<{ clearSelectionButtonLabel: string; selectedCountText: string; - }>("@store:selectedCounterTextsStore") + }>("@store:selectedCounterTextsStore"), + selectionStatusStore: token<{ + selectionStatus: string; + }>("@store:selectionStatusStore") }, setupService: token("DatagridSetupService"), @@ -132,6 +135,7 @@ export const DG_TOKENS = { selectionCounterCfg: token<{ position: "top" | "bottom" | "off" }>("SelectionCounterConfig"), selectionCounterVM: token("SelectionCounterViewModel"), + selectionStatusVM: token<{ selectionStatus: string; isVisible: boolean }>("SelectionStatusViewModel"), selectionGate: token>("@gate:GateForSelectionHelper"), selectionHelper: token("@service:SelectionHelperService"), 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/core/models/selection.model.ts b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts index 0a023e8e0b..83816b99b0 100644 --- a/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts +++ b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts @@ -105,3 +105,35 @@ export function selectionCounterTextsStore( } }); } + +/** + * Observable that returns selection status text. + * When all items are selected, returns allSelectedText ("All X rows selected."). + * Otherwise, returns selectedCountText ("Y items selected"). + * + * This ensures screen reader announcements match visual text in SelectAllBar. + * @injectable + */ +export interface ObservableSelectionStatus { + selectionStatus: string; +} + +export function selectionStatusStore( + gate: DerivedPropsGate<{ + allSelectedText?: DynamicValue; + }>, + selectedCount: ComputedAtom, + selectedTexts: { selectedCountText: string }, + isAllItemsSelected: ComputedAtom +): ObservableSelectionStatus { + return observable({ + get selectionStatus() { + if (isAllItemsSelected.get()) { + const str = gate.props.allSelectedText?.value ?? "All %d rows selected."; + const count = selectedCount.get(); + return str.replace("%d", `${count}`); + } + return selectedTexts.selectedCountText; + } + }); +} diff --git a/packages/shared/widget-plugin-grid/src/main.ts b/packages/shared/widget-plugin-grid/src/main.ts index a535f3d93a..6db4b89370 100644 --- a/packages/shared/widget-plugin-grid/src/main.ts +++ b/packages/shared/widget-plugin-grid/src/main.ts @@ -12,3 +12,13 @@ export { SelectAllService } from "./select-all/SelectAll.service"; export * from "./selection/context"; export { createSelectionHelper } from "./selection/createSelectionHelper"; export { SelectActionsProvider } from "./selection/SelectActionsProvider.service"; +export { + selectedCountMultiAtom, + isAllItemsSelected, + isAllItemsSelectedAtom, + isCurrentPageSelected, + isCurrentPageSelectedAtom, + selectionCounterTextsStore, + selectionStatusStore +} from "./core/models/selection.model"; +export type { ObservableSelectionStatus } from "./core/models/selection.model"; 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 new file mode 100644 index 0000000000..796a242ccb --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection-counter/SelectionStatus.viewModel.ts @@ -0,0 +1,31 @@ +import { makeAutoObservable } from "mobx"; + +/** + * ViewModel for SelectionStatus component that provides screen reader announcements + * for selection state changes via ARIA live region. + * @injectable + */ +export class SelectionStatusViewModel { + constructor( + private selectionStatusStore: { selectionStatus: string }, + private selectionType: "Single" | "Multi" | "None" + ) { + makeAutoObservable(this); + } + + /** + * Returns true if the status region should be visible. + * Only visible when selection is enabled (not "None"). + */ + get isVisible(): boolean { + return this.selectionType !== "None"; + } + + /** + * Returns the current selection status text. + * Uses smart logic that handles "all items selected" vs partial selection. + */ + get selectionStatus(): string { + return this.selectionStatusStore.selectionStatus; + } +}