fix(PersonMentionEditor): enforce disabled state on the contenteditable
Wrapping the editor with pointer-events-none was visual-only — keyboard users could still tab into the contenteditable and type. Wire `editable: !disabled` on the Tiptap Editor and a reactive `$effect` that calls setEditable when the prop flips after mount; expose `aria-disabled="true"` on the wrapper so screen readers announce the deactivated state. Tests assert contenteditable=false and aria-disabled=true when disabled; contenteditable=true otherwise. Closes WCAG 2.1.1 / 4.1.2 — Felix #5615 + Leonie #5621 + Nora #5618 BLOCKER. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,11 @@ onMount(() => {
|
|||||||
|
|
||||||
editor = new Editor({
|
editor = new Editor({
|
||||||
element: editorEl,
|
element: editorEl,
|
||||||
|
// Initial editable state honors the `disabled` prop. The reactive
|
||||||
|
// $effect below keeps it in sync if the prop flips after mount —
|
||||||
|
// without this, a keyboard user can tab into the contenteditable
|
||||||
|
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
||||||
|
editable: !disabled,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
@@ -232,11 +237,19 @@ onMount(() => {
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
||||||
|
// flips contenteditable on the inner DOM and stops accepting input — matches
|
||||||
|
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
||||||
|
$effect(() => {
|
||||||
|
editor?.setEditable(!disabled);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||||
class:opacity-50={disabled}
|
class:opacity-50={disabled}
|
||||||
class:pointer-events-none={disabled}
|
class:pointer-events-none={disabled}
|
||||||
|
aria-disabled={disabled ? 'true' : undefined}
|
||||||
bind:this={editorEl}
|
bind:this={editorEl}
|
||||||
></div>
|
></div>
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ function mockFetchEmpty() {
|
|||||||
|
|
||||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||||
|
|
||||||
function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
|
function renderHost(
|
||||||
|
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
|
||||||
|
) {
|
||||||
let snapshot: Snapshot = {
|
let snapshot: Snapshot = {
|
||||||
value: initial.value ?? '',
|
value: initial.value ?? '',
|
||||||
mentionedPersons: initial.mentionedPersons ?? []
|
mentionedPersons: initial.mentionedPersons ?? []
|
||||||
@@ -55,6 +57,7 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
|
|||||||
render(PersonMentionEditorHost, {
|
render(PersonMentionEditorHost, {
|
||||||
initialValue: initial.value ?? '',
|
initialValue: initial.value ?? '',
|
||||||
initialMentions: initial.mentionedPersons ?? [],
|
initialMentions: initial.mentionedPersons ?? [],
|
||||||
|
disabled: initial.disabled ?? false,
|
||||||
onChange: (snap: Snapshot) => {
|
onChange: (snap: Snapshot) => {
|
||||||
snapshot = snap;
|
snapshot = snap;
|
||||||
}
|
}
|
||||||
@@ -280,6 +283,39 @@ describe('PersonMentionEditor — keyboard navigation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — disabled state', () => {
|
||||||
|
it('sets contenteditable=false on the editor when disabled', async () => {
|
||||||
|
renderHost({ value: 'Bestehender Text', disabled: true });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
expect(textbox!.getAttribute('contenteditable')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
|
||||||
|
renderHost({ disabled: true });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const wrapper = document.querySelector('[aria-disabled="true"]');
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
|
||||||
|
renderHost({ disabled: false });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
expect(textbox!.getAttribute('contenteditable')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — touch target', () => {
|
describe('PersonMentionEditor — touch target', () => {
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ type Props = {
|
|||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
initialMentions?: PersonMention[];
|
initialMentions?: PersonMention[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props();
|
let {
|
||||||
|
initialValue = '',
|
||||||
|
initialMentions = [],
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
onChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// initial* props seed mount-time state; reading them inside untrack signals
|
// initial* props seed mount-time state; reading them inside untrack signals
|
||||||
// the intentional one-shot capture and silences state_referenced_locally.
|
// the intentional one-shot capture and silences state_referenced_locally.
|
||||||
@@ -28,4 +35,5 @@ $effect(() => {
|
|||||||
bind:value={value}
|
bind:value={value}
|
||||||
bind:mentionedPersons={mentionedPersons}
|
bind:mentionedPersons={mentionedPersons}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user