Skip to content
Open
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
65 changes: 0 additions & 65 deletions .github/a11y-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -803,71 +803,6 @@
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast?application=playwright"
},
{
"key": "RichTextEditor::Controlled Persistence::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::Custom Transformers::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::Default::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::Error Status::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::Imperative Ref::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::Markdown Serializers::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::Markdown Serializers::label",
"impact": "critical",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/label?application=playwright"
},
{
"key": "RichTextEditor::Read Only::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::Required::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::With Character Limit::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::With Description::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::With Initial Value::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "RichTextEditor::With Toolbar::aria-input-field-name",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-input-field-name?application=playwright"
},
{
"key": "Schedule::Async Loader::color-contrast",
"impact": "serious",
Expand Down
20 changes: 13 additions & 7 deletions apps/storybook/stories/RichTextEditor.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export const WithCharacterLimit: Story = {
args: {
label: 'Bio',
maxLength: 80,
description: 'A character counter appears below the editor when maxLength is set.',
description:
'A character counter appears below the editor when maxLength is set.',
placeholder: 'Type past 80 characters to see the counter turn red…',
},
};
Expand Down Expand Up @@ -159,7 +160,7 @@ export const ControlledPersistence = {
<div style={{fontWeight: 600, marginBottom: 8}}>
RichTextView (read-only render of the same content)
</div>
<RichTextView value={json} />
<RichTextView value={json} label="Saved content preview" />
</div>
</div>
);
Expand Down Expand Up @@ -190,7 +191,9 @@ export const ImperativeRef = {
onClick={() => {
const state = ref.current?.getEditorState();
const text = state?.read(() => $getRoot().getTextContent());
setReadout(`getEditorState() text content: ${JSON.stringify(text)}`);
setReadout(
`getEditorState() text content: ${JSON.stringify(text)}`,
);
}}>
getEditorState()
</button>
Expand Down Expand Up @@ -277,12 +280,15 @@ export const MarkdownSerializers = {
return (
<div style={{display: 'grid', gap: 24, maxWidth: 720}}>
<div>
<div style={{fontWeight: 600, marginBottom: 8}}>
<label
htmlFor="rte-markdown-input"
style={{display: 'block', fontWeight: 600, marginBottom: 8}}>
1. Input Markdown (edit me)
</div>
</label>
<textarea
id="rte-markdown-input"
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
onChange={e => setMarkdown(e.target.value)}
rows={10}
style={{
width: '100%',
Expand Down Expand Up @@ -314,7 +320,7 @@ export const MarkdownSerializers = {
<div style={{fontWeight: 600, marginBottom: 8}}>
3. Same JSON rendered read-only via RichTextView
</div>
<RichTextView value={json} />
<RichTextView value={json} label="Serialized content preview" />
</div>

<div>
Expand Down
25 changes: 25 additions & 0 deletions packages/lab/src/RichTextEditor/RichTextEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ describe('RichTextEditor', () => {
expect(screen.getByText('Notes')).toBeInTheDocument();
});

it('names the textbox from the visible label', () => {
// The contenteditable surface is a <div>, so a <label htmlFor> association
// does not apply — the accessible name must come from aria-labelledby
// pointing at the label element's id (axe: aria-input-field-name).
render(<RichTextEditor label="Notes" />);
expect(screen.getByRole('textbox', {name: 'Notes'})).toBeInTheDocument();
});

it('shows the placeholder when empty', () => {
render(<RichTextEditor label="Notes" placeholder="Write something…" />);
expect(screen.getByText('Write something…')).toBeInTheDocument();
Expand Down Expand Up @@ -573,6 +581,23 @@ describe('RichTextView', () => {
);
});

it('names the read-only textbox with a default accessible name', () => {
// Lexical's ContentEditable keeps role="textbox" (with aria-readonly) even
// when non-editable, so the region must carry an accessible name
// (axe: aria-input-field-name).
render(<RichTextView value={HELLO_STATE} />);
expect(
screen.getByRole('textbox', {name: 'Rich text content'}),
).toBeInTheDocument();
});

it('names the read-only textbox from the label prop', () => {
render(<RichTextView value={HELLO_STATE} label="Meeting notes" />);
expect(
screen.getByRole('textbox', {name: 'Meeting notes'}),
).toBeInTheDocument();
});

it('renders custom read-only plugins passed via the plugins prop', () => {
render(
<RichTextView
Expand Down
14 changes: 11 additions & 3 deletions packages/lab/src/RichTextEditor/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ export const RichTextEditor = forwardRef<
) {
const size = useSize(sizeProp, 'md');
const id = useId();
const labelID = useId();
const descriptionID = useId();
const statusMessageID = useId();
const placeholderID = useId();
Expand Down Expand Up @@ -465,6 +466,7 @@ export const RichTextEditor = forwardRef<
isLabelHidden={isLabelHidden}
description={description}
inputID={id}
labelID={labelID}
descriptionID={description ? descriptionID : undefined}
isOptional={isOptional}
isRequired={isRequired}
Expand Down Expand Up @@ -503,8 +505,9 @@ export const RichTextEditor = forwardRef<
<RichTextPlugin
contentEditable={
<EditorContentEditable
id={id}
ariaLabel={isLabelHidden ? label : undefined}
ariaLabelledBy={isLabelHidden ? undefined : id}
ariaLabelledBy={isLabelHidden ? undefined : labelID}
ariaDescribedBy={ariaDescribedBy}
ariaRequired={isRequired && !isOptional}
ariaInvalid={status?.type === 'error'}
Expand Down Expand Up @@ -629,7 +632,9 @@ function EditorRefBridge({
// $generateHtmlFromNodes serializes the whole document (null selection)
// to HTML; must run in a read context and requires a DOM.
// `@lexical/html` is a subpackage (built dist) — safe.
editor.getEditorState().read(() => $generateHtmlFromNodes(editor, null)),
editor
.getEditorState()
.read(() => $generateHtmlFromNodes(editor, null)),
getEditor: () => editor,
}),
[editor, editable, transformers],
Expand Down Expand Up @@ -659,7 +664,7 @@ function CharCountPlugin({
// `declare` class fields) and fails. Both APIs used here are methods on the
// editor instance, so no top-level `lexical` value import is needed.
onCountChange(editor.getRootElement()?.textContent?.length ?? 0);
return editor.registerTextContentListener((textContent) => {
return editor.registerTextContentListener(textContent => {
onCountChange(textContent.length);
});
}, [editor, onCountChange]);
Expand All @@ -672,6 +677,7 @@ function CharCountPlugin({
* neither) is satisfied by two concrete branches rather than a spread.
*/
function EditorContentEditable({
id,
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
Expand All @@ -681,6 +687,7 @@ function EditorContentEditable({
placeholderID,
rest,
}: {
id: string;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
Expand All @@ -691,6 +698,7 @@ function EditorContentEditable({
rest: Record<string, unknown>;
}) {
const shared = {
id,
role: 'textbox' as const,
'aria-multiline': 'true' as const,
'aria-label': ariaLabel,
Expand Down
13 changes: 11 additions & 2 deletions packages/lab/src/RichTextEditor/RichTextView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* (lab), re-exported from @astryxdesign/lab
*
* SYNC: When modified, update these files to stay in sync:
* - /packages/lab/src/RichTextEditor/RichTextView.test.tsx
* - /packages/lab/src/RichTextEditor/RichTextEditor.test.tsx (RichTextView describe block)
* - /packages/lab/src/RichTextEditor/index.ts
* - /packages/lab/src/index.ts (barrel re-export)
* - /apps/storybook/stories/RichTextEditor.stories.tsx
Expand Down Expand Up @@ -57,6 +57,14 @@ export interface RichTextViewProps extends BaseProps {
* `JSON.stringify(editorState.toJSON())`).
*/
value: string;
/**
* Accessible name for the read-only text region. Lexical renders the region
* with `role="textbox"` (plus `aria-readonly`) even when non-editable, so it
* must carry a name for screen readers. Override with something contextual
* (e.g. `'Meeting notes'`).
* @default 'Rich text content'
*/
label?: string;
/**
* Additional Lexical nodes to register beyond the default OSS set. Must match
* the nodes used to author `value` so custom node types deserialize.
Expand Down Expand Up @@ -98,6 +106,7 @@ export interface RichTextViewProps extends BaseProps {
*/
export function RichTextView({
value,
label = 'Rich text content',
nodes,
plugins,
namespace = 'astryx-view',
Expand Down Expand Up @@ -171,7 +180,7 @@ export function RichTextView({
{...rest}>
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
contentEditable={<ContentEditable aria-label={label} />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
Expand Down
Loading