From 789a28d784068b8fb57b2ad422263281a013a8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:36:32 +0000 Subject: [PATCH 1/2] fix(fields): name the boolean cell's read-only checkbox by its field The boolean cell renderer's plain-checkbox outcome drew a checkbox with no accessible name, so a screen reader announced every grid boolean cell as a bare "checkbox". It is now named by the field's label, else its humanized name: one local value, also read by the status field's off badge. The fallback humanizes the name as authored rather than the lower-cased matching key. Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .changeset/12584-boolean-checkbox-name.md | 11 ++ .../booleanCell.accessibleName-12584.test.tsx | 115 ++++++++++++++++++ packages/fields/src/index.tsx | 16 ++- 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 .changeset/12584-boolean-checkbox-name.md create mode 100644 packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx diff --git a/.changeset/12584-boolean-checkbox-name.md b/.changeset/12584-boolean-checkbox-name.md new file mode 100644 index 0000000000..2fe8423f5d --- /dev/null +++ b/.changeset/12584-boolean-checkbox-name.md @@ -0,0 +1,11 @@ +--- +'@object-ui/fields': patch +--- + +A boolean cell's read-only checkbox is named by its field (objectui#12584). + +The boolean cell renderer draws a plain checkbox for every boolean column outside the completion and status names. It had no accessible name, so a screen reader announced each cell of the Studio Records grid and the runtime list grid as a bare "checkbox, not checked", with nothing to say which field it was. The checkbox is now named by the field's label, or by its humanized name when the field declares no label, the same words the status field's "Off" badge prints. The name carries no state word: the checkbox role announces checked or not checked itself. + +The humanized-name fallback now reads the field name as authored, for the checkbox and the "Off" badge alike. It read the lower-cased name before, so an unlabelled `IsPreferred` read `Ispreferred`. A spec-legal field name is lowercase snake_case already, so for every such field the words are unchanged. + +Nothing else moves: no visual change, no new language-pack key, and no export, prop or type member. diff --git a/packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx b/packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx new file mode 100644 index 0000000000..91938f6005 --- /dev/null +++ b/packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx @@ -0,0 +1,115 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12584 — the boolean cell's plain checkbox is named by its field. + * + * `BooleanCellRenderer` has three outcomes for a real boolean. Two already + * named themselves: the completion indicator (`role="img"`, "Completed" / + * "Not completed") and the status field's off badge ("LABEL — Off"). The + * third, the plain read-only checkbox every other boolean column draws, had + * no `aria-label`, no `aria-labelledby` and no text beside it, so a screen + * reader announced each grid cell as a bare "checkbox, not checked" with no + * field (measured on the Studio Records grid, HotCRM Account `is_active`). + * + * The name is the field's words, the ones the off badge already prints: its + * label, else its humanized name. It carries no state word: the `checkbox` + * role announces checked / not checked itself. + * + * Every naming case is found by `getByRole('checkbox', { name })`, so it reads + * the name off the element that carries the role (Radix's button), not off a + * wrapper. Every case renders through the registry exactly as a grid host + * resolves a read-mode cell, for both types that map to this renderer. + * + * The CONTROL block pins what this change must leave alone: the completion + * indicator's two names, the off badge's text, and the non-boolean + * `EmptyValue` outcome with no checkbox in it. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getCellRenderer, resolveCellRendererType } from '../index'; + +afterEach(() => cleanup()); + +const TYPES = ['boolean', 'toggle'] as const; + +/** Resolve + render exactly the way a consumer builds a read-mode cell. */ +function renderCell(type: string, value: unknown, field: Record) { + const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type); + return render(); +} + +describe('objectui#12584 — the boolean cell checkbox is named by its field', () => { + for (const type of TYPES) { + describe(`\`${type}\``, () => { + it('a labelled field: a checked box is named by the label', () => { + renderCell(type, true, { name: 'is_preferred', label: 'Preferred' }); + const box = screen.getByRole('checkbox', { name: 'Preferred' }); + expect(box).toBeChecked(); + }); + + it('a labelled field: an unchecked box is named by the label', () => { + renderCell(type, false, { name: 'is_preferred', label: 'Preferred' }); + const box = screen.getByRole('checkbox', { name: 'Preferred' }); + expect(box).not.toBeChecked(); + }); + + it('an unlabelled field falls back to the humanized name', () => { + renderCell(type, true, { name: 'is_preferred' }); + expect(screen.getByRole('checkbox', { name: 'Is Preferred' })).toBeChecked(); + }); + + it('an unlabelled mixed-case name keeps the spelling it was authored with', () => { + // The renderer matches on the lower-cased name; the words it draws + // read the name as authored, or this read `Ispreferred`. + renderCell(type, false, { name: 'IsPreferred' }); + expect(screen.getByRole('checkbox', { name: 'IsPreferred' })).not.toBeChecked(); + }); + + it('a status field that is on draws the box, named by the label (the measured HotCRM `is_active` cell)', () => { + renderCell(type, true, { name: 'is_active', label: 'Active' }); + expect(screen.getByRole('checkbox', { name: 'Active' })).toBeChecked(); + }); + }); + } +}); + +describe('objectui#12584 — CONTROL: the outcomes that already named themselves are unchanged', () => { + it('the completion indicator keeps its two names and draws no checkbox', () => { + renderCell('boolean', true, { name: 'completed', label: 'Done?' }); + expect(screen.getByRole('img', { name: 'Completed' })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).toBeNull(); + cleanup(); + + renderCell('boolean', false, { name: 'is_done' }); + expect(screen.getByRole('img', { name: 'Not completed' })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).toBeNull(); + }); + + it('the off badge keeps its text and draws no checkbox', () => { + const labelled = renderCell('boolean', false, { name: 'is_active', label: 'Active' }); + expect(labelled.getByTestId('boolean-warning-badge')).toHaveTextContent(/^Active — Off$/); + expect(screen.queryByRole('checkbox')).toBeNull(); + cleanup(); + + const unlabelled = renderCell('boolean', false, { name: 'is_active' }); + expect(unlabelled.getByTestId('boolean-warning-badge')).toHaveTextContent(/^Is Active — Off$/); + expect(screen.queryByRole('checkbox')).toBeNull(); + }); + + it('a non-boolean value draws the EmptyValue affordance and no checkbox', () => { + const { container } = renderCell('boolean', 'true', { name: 'is_preferred', label: 'Preferred' }); + expect(screen.queryByRole('checkbox')).toBeNull(); + const empty = container.querySelector('[data-slot="empty-value"]'); + expect(empty, 'the shared EmptyValue affordance must be present').not.toBeNull(); + expect(empty?.getAttribute('aria-label')).toBe('No value'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 4ab20c6ec8..8d8a77e75a 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -985,6 +985,14 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React. const fieldName = field?.name?.toLowerCase() || ''; const isCompletionField = fieldName === 'completed' || fieldName === 'is_completed' || fieldName === 'done' || fieldName === 'is_done'; + // The field's words, ONE value read by both faces below that name the field + // (objectui#12584): the off badge's text and the plain checkbox's accessible + // name. Its label, else its humanized name. The fallback reads the name as + // authored, not the lower-cased matching key above: `humanizeLabel` only + // capitalises, so the key turned an unlabelled `IsPreferred` into + // `Ispreferred`. A spec-legal name is lowercase snake_case already, so for + // it the two spellings are the same string. + const fieldLabel = field?.label || humanizeLabel(field?.name || ''); if (isCompletionField) { return ( @@ -1004,14 +1012,18 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React. if (STATUS_FIELD_NAMES.has(fieldName) && value === false) { return ( - {t('fields.boolean.offBadge', { label: field?.label || humanizeLabel(fieldName) })} + {t('fields.boolean.offBadge', { label: fieldLabel })} ); } return (
- + {/* Named by the field (objectui#12584): with no name a screen reader + announced every cell as a bare "checkbox". The `checkbox` role + already announces checked / not checked, so the name carries no + state word. */} +
); } From 63ba527469177cd56f7ce41345f84207cb072a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:52:44 +0000 Subject: [PATCH 2/2] test(fields): type the 12584 cell helper without an explicit any Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .../src/__tests__/booleanCell.accessibleName-12584.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx b/packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx index 91938f6005..abcb9e5fef 100644 --- a/packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx +++ b/packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx @@ -44,7 +44,7 @@ const TYPES = ['boolean', 'toggle'] as const; /** Resolve + render exactly the way a consumer builds a read-mode cell. */ function renderCell(type: string, value: unknown, field: Record) { const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type); - return render(); + return render(); } describe('objectui#12584 — the boolean cell checkbox is named by its field', () => {