Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ca25ed4
test(accessibility): define editor placeholder RED
seonghobae Aug 10, 2026
f231385
feat(accessibility): expose normalized editor placeholder
seonghobae Aug 10, 2026
d533be4
feat(accessibility): bind visual placeholder to textbox semantics
seonghobae Aug 10, 2026
d2b1b7a
feat(accessibility): align collaborative placeholder semantics
seonghobae Aug 10, 2026
0b70ec5
test(accessibility): verify placeholder semantics on both editor surf…
seonghobae Aug 10, 2026
553e21d
docs(accessibility): record editor placeholder semantics
seonghobae Aug 10, 2026
c5d4312
test(docs): bind accessible placeholder authority
seonghobae Aug 10, 2026
2e38faa
test(accessibility): bind placeholder name separation semantically
seonghobae Aug 10, 2026
afc1757
test(accessibility): expose visual placeholder normalization drift
seonghobae Aug 10, 2026
7aa01ab
fix(accessibility): share placeholder normalization
seonghobae Aug 10, 2026
79c3003
fix(accessibility): normalize standalone visual placeholder
seonghobae Aug 10, 2026
e4b3182
fix(accessibility): normalize collaborative visual placeholder
seonghobae Aug 10, 2026
a2bd2cc
test(package): smoke public placeholder semantics
seonghobae Aug 10, 2026
5af4bb6
fix(accessibility): keep placeholder callback live
seonghobae Aug 10, 2026
e9571b2
fix(accessibility): refresh standalone visual placeholder
seonghobae Aug 10, 2026
fe77e5a
fix(accessibility): refresh collaborative visual placeholder
seonghobae Aug 10, 2026
0b40373
test(package): run public placeholder smoke verification
seonghobae Aug 10, 2026
0c1915d
docs(accessibility): align placeholder verification authority
seonghobae Aug 10, 2026
3a4803b
test(package): bind placeholder smoke to npm pack inventory
seonghobae Aug 10, 2026
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
39 changes: 39 additions & 0 deletions docs/doctoring/editor-placeholder-accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Editor placeholder accessibility

Status: Implemented on active PR
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Purpose

Inkspan's visual empty-editor hint is rendered by the TipTap Placeholder extension. The active accessibility change mirrors that same host-supplied placeholder into the ProseMirror textbox's `aria-placeholder` attribute so assistive-technology users can receive equivalent entry guidance without requiring every embedding host to duplicate the text in a separate description element.

The placeholder remains **supplemental guidance**, not the editor's accessible name. Inkspan's existing accessible-name precedence remains unchanged: `aria-labelledby` whenever a host supplies a non-blank label reference, otherwise an explicit `aria-label`, otherwise the product fallback label.

## WAI-ARIA authority

WAI-ARIA 1.2 defines `aria-placeholder` as a short hint intended to aid data entry when a control has no value and allows it on the `textbox` role. The Recommendation also states that placeholder text must not be used instead of a label because users still need to understand the input's purpose once a value is present.

Inkspan therefore exposes the placeholder only after trimming surrounding whitespace and omits the attribute when the configured visual placeholder is blank or whitespace-only. It never promotes the placeholder to `aria-label` and never removes the existing textbox name.

## Lifecycle and ownership

Standalone and provider-neutral collaborative surfaces use the same `buildEditorAccessibilityAttributes()` contract. A changed React `placeholder` prop updates the semantic textbox attribute and the visual TipTap placeholder from one normalized value without replacing the current TipTap editor or Yjs document binding. The change introduces no live region, network call, model call, persistence field, telemetry event, tenant identifier, authorization state, or collaboration-provider behavior.

`aria-placeholder` does not assert that the document is editable. `aria-readonly` and the TipTap editable state remain the authority for editability. A read-only empty surface can still expose its configured placeholder guidance, but that guidance grants no editing capability.

## Verification

The active test line includes:

- a focused historical RED proving the accessibility builder had no placeholder input or attribute contract;
- normalized non-empty placeholder plus `aria-labelledby` name precedence;
- standalone DOM verification and live placeholder-prop update without editor recreation;
- collaborative DOM verification and live placeholder-prop update without editor or Yjs-fragment replacement;
- blank/whitespace-only placeholder omission;
- package-distribution verification through `pnpm build && pnpm verify:package`, whose npm-pack inventory check binds `dist/cwl-editor.js` to the publishable package and whose `node ./tests/package/verify-editor-placeholder-package.mjs` smoke verifies the public `CwlEditor.placeholder` visual and `aria-placeholder` semantics from that built entry; and
- repository-wide exact production coverage, package, CI, security, and SAST gates before protected integration.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## References — APA 7th

World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2* (W3C Recommendation). https://www.w3.org/TR/wai-aria-1.2/

W3C Web Accessibility Initiative. (n.d.). *Providing accessible names and descriptions*. ARIA Authoring Practices Guide. Retrieved August 10, 2026, from https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
"import": "./dist/cwl-text-position-selector.js",
"require": "./dist/cwl-text-position-selector.cjs"
},
"./markdown": {
"types": "./dist/markdown/index.d.ts",
"import": "./dist/cwl-markdown.js",
"require": "./dist/cwl-markdown.cjs"
},
"./styles.css": "./dist/cwl-editor.css",
"./fonts.css": "./src/fonts/fonts.css",
"./fonts-latin.css": "./src/fonts/fonts-latin.css",
Expand All @@ -94,16 +99,16 @@
],
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && node ./scripts/copy-styles.mjs",
"build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs",
"build:demo": "vite build --config vite.demo.config.ts",
"fonts": "node ./scripts/fetch-fonts.mjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs",
"verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs"
"test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs",
"verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand Down
14 changes: 12 additions & 2 deletions src/collaboration/CollaborativeCwlEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
useState,
} from 'react';
import { EditorFrame } from '../components/EditorFrame.js';
import { buildEditorAccessibilityAttributes } from '../components/editorAccessibility.js';
import {
buildEditorAccessibilityAttributes,
normalizeEditorPlaceholder,
} from '../components/editorAccessibility.js';
import { createEditorDocumentSnapshot } from '../components/editorDocumentSnapshot.js';
import { applyEditorFormReset } from '../components/editorFormReset.js';
import { editorHtmlToValue } from '../components/editorSerialization.js';
Expand Down Expand Up @@ -106,6 +109,10 @@ export const CollaborativeCwlEditor = forwardRef<
}

const normalizedField = field.trim();
const normalizedPlaceholder = useMemo(
() => normalizeEditorPlaceholder(placeholder),
[placeholder],
);
const cursorUser = user ? serializeCollaborationUser(user) : undefined;
const presenceEnabled = provider !== undefined && cursorUser !== undefined;
const scopedProvider = useMemo(
Expand Down Expand Up @@ -134,6 +141,7 @@ export const CollaborativeCwlEditor = forwardRef<
const onReadyRef = useLatestRef(onReady);
const onDestroyRef = useLatestRef(onDestroy);
const onFormResetRef = useLatestRef(onFormReset);
const placeholderRef = useLatestRef(normalizedPlaceholder ?? '');
const reportImageError = useCallback((error: Error) => {
onImageErrorRef.current?.(error);
}, [onImageErrorRef]);
Expand All @@ -147,6 +155,7 @@ export const CollaborativeCwlEditor = forwardRef<
() =>
buildEditorAccessibilityAttributes({
defaultLabel: 'Collaborative rich text editor',
placeholder: normalizedPlaceholder,
languageTag,
textDirection,
ariaLabel,
Expand All @@ -158,6 +167,7 @@ export const CollaborativeCwlEditor = forwardRef<
editable,
}),
[
normalizedPlaceholder,
languageTag,
textDirection,
ariaLabel,
Expand All @@ -175,7 +185,7 @@ export const CollaborativeCwlEditor = forwardRef<
immediatelyRender: false,
editable,
extensions: buildExtensions({
placeholder,
placeholder: () => placeholderRef.current,
image,
clipboard,
onImageError: reportImageError,
Expand Down
102 changes: 102 additions & 0 deletions src/components/CwlEditor.accessiblePlaceholder.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { CollaborativeCwlEditor } from '../collaboration/CollaborativeCwlEditor.js';
import type { CwlEditorHandle } from '../types.js';
import { CwlEditor } from './CwlEditor.js';

afterEach(cleanup);

const visualPlaceholder = (textbox: HTMLElement): string | null =>
textbox.querySelector('[data-placeholder]')?.getAttribute('data-placeholder') ??
null;

describe('accessible editor placeholder semantics', () => {
it('keeps standalone visual and semantic placeholder guidance normalized together', async () => {
const editorRef = createRef<CwlEditorHandle>();
const { rerender } = render(
<CwlEditor
ref={editorRef}
ariaLabel="Report editor"
placeholder=" Start the report… "
/>,
);

const textbox = await screen.findByRole('textbox', { name: 'Report editor' });
await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull());
const editor = editorRef.current!.getEditor();
expect(textbox).toHaveAttribute('aria-placeholder', 'Start the report…');
expect(visualPlaceholder(textbox)).toBe('Start the report…');

rerender(
<CwlEditor
ref={editorRef}
ariaLabel="Report editor"
placeholder="Continue with evidence…"
/>,
);
await waitFor(() =>
expect(textbox).toHaveAttribute(
'aria-placeholder',
'Continue with evidence…',
),
);
expect(visualPlaceholder(textbox)).toBe('Continue with evidence…');
expect(editorRef.current!.getEditor()).toBe(editor);

rerender(
<CwlEditor
ref={editorRef}
ariaLabel="Report editor"
placeholder=" "
/>,
);
await waitFor(() => expect(textbox).not.toHaveAttribute('aria-placeholder'));
expect(visualPlaceholder(textbox)).toBeNull();
expect(editorRef.current!.getEditor()).toBe(editor);
});

it('keeps collaborative visual and semantic placeholder updates Yjs-preserving', async () => {
const collaborationDocument = new Y.Doc();
const editorRef = createRef<CwlEditorHandle>();
try {
const { rerender } = render(
<CollaborativeCwlEditor
ref={editorRef}
document={collaborationDocument}
ariaLabel="Shared report editor"
placeholder=" Shared report… "
hideToolbar
/>,
);

const textbox = await screen.findByRole('textbox', {
name: 'Shared report editor',
});
await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull());
const editor = editorRef.current!.getEditor();
const sharedFragment = collaborationDocument.getXmlFragment('default');
expect(textbox).toHaveAttribute('aria-placeholder', 'Shared report…');
expect(visualPlaceholder(textbox)).toBe('Shared report…');

rerender(
<CollaborativeCwlEditor
ref={editorRef}
document={collaborationDocument}
ariaLabel="Shared report editor"
placeholder="Review together…"
hideToolbar
/>,
);
await waitFor(() =>
expect(textbox).toHaveAttribute('aria-placeholder', 'Review together…'),
);
expect(visualPlaceholder(textbox)).toBe('Review together…');
expect(editorRef.current!.getEditor()).toBe(editor);
expect(collaborationDocument.getXmlFragment('default')).toBe(sharedFragment);
} finally {
collaborationDocument.destroy();
}
});
});
14 changes: 12 additions & 2 deletions src/components/CwlEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type { ClipboardSanitizationError } from '../extensions/SafeClipboard.js'
import { buildExtensions } from '../extensions/kit.js';
import type { CwlEditorHandle, CwlEditorProps } from '../types.js';
import { EditorFrame } from './EditorFrame.js';
import { buildEditorAccessibilityAttributes } from './editorAccessibility.js';
import {
buildEditorAccessibilityAttributes,
normalizeEditorPlaceholder,
} from './editorAccessibility.js';
import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js';
import { applyEditorFormReset } from './editorFormReset.js';
import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js';
Expand Down Expand Up @@ -87,10 +90,16 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(
},
[onClipboardErrorRef],
);
const normalizedPlaceholder = useMemo(
() => normalizeEditorPlaceholder(placeholder),
[placeholder],
);
const placeholderRef = useLatestRef(normalizedPlaceholder ?? '');
const editorAttributes = useMemo(
() =>
buildEditorAccessibilityAttributes({
defaultLabel: 'Rich text editor',
placeholder: normalizedPlaceholder,
languageTag,
textDirection,
ariaLabel,
Expand All @@ -102,6 +111,7 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(
editable,
}),
[
normalizedPlaceholder,
languageTag,
textDirection,
ariaLabel,
Expand All @@ -118,7 +128,7 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(
immediatelyRender: false,
editable,
extensions: buildExtensions({
placeholder,
placeholder: () => placeholderRef.current,
image,
clipboard,
onImageError: reportImageError,
Expand Down
28 changes: 22 additions & 6 deletions src/components/editorAccessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ export type EditorAriaInvalid = boolean | 'grammar' | 'spelling';
export interface EditorAccessibilityOptions {
/** Fallback accessible name when no host label reference is supplied. */
defaultLabel: string;
/** Visual empty-editor guidance mirrored to `aria-placeholder` when non-blank. */
placeholder?: string;
/** BCP 47 language tag for the authored document. */
languageTag?: string;
/** Base writing direction for the authored document. */
textDirection?: EditorTextDirection;
/** Explicit string accessible name for the editable surface. */
ariaLabel?: string;
/** Space-separated IDs of visible elements that label the surface. */
/** Space-separated IDs of elements that label the surface. */
ariaLabelledBy?: string;
/** Space-separated IDs of elements that describe the surface. */
ariaDescribedBy?: string;
Expand All @@ -27,26 +29,39 @@ export interface EditorAccessibilityOptions {
editable: boolean;
}

/** Normalize a host-supplied language, accessible-name, or ID-reference string. */
/** Normalize an optional host-supplied accessibility string. */
function normalizedAccessibilityValue(
value: string | undefined,
): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}

/**
* Normalize the shared visual and semantic empty-editor guidance.
*
* Returning `undefined` for blank input lets callers omit both the visual
* Placeholder extension text and `aria-placeholder` from the same source.
*/
export function normalizeEditorPlaceholder(
value: string | undefined,
): string | undefined {
return normalizedAccessibilityValue(value);
}

/**
* Build the complete semantic attribute contract shared by standalone and
* collaborative editor surfaces.
*
* A visible label referenced with `aria-labelledby` takes precedence over the
* fallback string label. Optional language and ID-reference values are omitted
* when blank so browsers and assistive technologies never receive empty
* metadata relationships.
* A non-blank `aria-labelledby` reference takes precedence over the fallback
* string label. Optional placeholder, language, and ID-reference values are
* omitted when blank. Placeholder guidance remains supplemental and never
* replaces the accessible name.
*/
export function buildEditorAccessibilityAttributes(
options: EditorAccessibilityOptions,
): Record<string, string> {
const placeholder = normalizeEditorPlaceholder(options.placeholder);
const languageTag = normalizedAccessibilityValue(options.languageTag);
const labelledBy = normalizedAccessibilityValue(options.ariaLabelledBy);
const describedBy = normalizedAccessibilityValue(options.ariaDescribedBy);
Expand All @@ -59,6 +74,7 @@ export function buildEditorAccessibilityAttributes(
'aria-readonly': String(!options.editable),
};

if (placeholder) attributes['aria-placeholder'] = placeholder;
if (languageTag) attributes.lang = languageTag;
if (options.textDirection) attributes.dir = options.textDirection;
if (labelledBy) {
Expand Down
22 changes: 22 additions & 0 deletions src/components/editorAccessibilityPlaceholder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildEditorAccessibilityAttributes } from './editorAccessibility.js';

describe('editor accessible placeholder contract', () => {
it('exposes normalized placeholder guidance without replacing the accessible name', () => {
expect(
buildEditorAccessibilityAttributes({
defaultLabel: 'Rich text editor',
ariaLabelledBy: 'editor-label',
placeholder: ' Start writing… ',
editable: true,
}),
).toEqual({
class: 'cwl-editor__content',
role: 'textbox',
'aria-multiline': 'true',
'aria-readonly': 'false',
'aria-labelledby': 'editor-label',
'aria-placeholder': 'Start writing…',
});
});
});
25 changes: 25 additions & 0 deletions src/editorPlaceholderDocumentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

import { describe, expect, it } from 'vitest';

const repositoryFile = (path: string): string =>
readFileSync(resolve(process.cwd(), path), 'utf8');

describe('editor placeholder accessibility documentation', () => {
it('keeps placeholder guidance separate from accessible-name authority', () => {
const doctoring = repositoryFile(
'docs/doctoring/editor-placeholder-accessibility.md',
);

expect(doctoring).toContain('Status: Implemented on active PR');
expect(doctoring).toContain('aria-placeholder');
expect(doctoring).toContain('supplemental guidance');
expect(doctoring).toContain('aria-labelledby');
expect(doctoring).toContain(
'It never promotes the placeholder to `aria-label`',
);
expect(doctoring).toContain('WAI-ARIA 1.2');
expect(doctoring).toContain('World Wide Web Consortium. (2023, June 6).');
});
});
Loading
Loading