diff --git a/docs/TRD.md b/docs/TRD.md index 80cec07a..989019b1 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -117,6 +117,8 @@ Identity inspection returns no partial routing object on malformed input. An unk Toolbar shortcut metadata is implemented on protected `main`. Shipped keyboard behavior, focus behavior, native controls, `aria-pressed`, `aria-keyshortcuts`, programmatic save/conflict state, and visible shortcut documentation must agree. Repository-level keyboard behavior outranks extension-local defaults when determining metadata. Status must not depend on color alone. Inkspan exposes machine state sufficient for host WCAG-oriented messaging while leaving localization and application-specific live-region policy to the host. +Active PR #152 repairs the shared link-editing shortcut so read-only editors do not open an editing prompt or issue a link command. Both Ctrl+K and Meta+K follow live editability changes without replacing the editor or discarding its document. Editable shortcut behavior remains unchanged. This proposal is not protected-main implementation or a new host authorization boundary. + Accessible editor placeholder semantics are implemented on protected `main`. Standalone and collaborative textbox surfaces expose the same normalized non-blank host-supplied visual placeholder through `aria-placeholder`; whitespace-only guidance is omitted, and placeholder updates do not replace the current TipTap editor or host-owned Yjs binding. `aria-labelledby`/`aria-label` remain the accessible-name authority and placeholder guidance never grants editability. Protected paged-media behavior extends the same principle to print output: Inkspan-owned interactive UI does not become document content, placeholder instruction is suppressed rather than printed as authored text, and link semantics remain visually distinguishable without relying on color alone. diff --git a/src/components/CwlEditor.accessibility.test.tsx b/src/components/CwlEditor.accessibility.test.tsx index c6b34863..d430fa19 100644 --- a/src/components/CwlEditor.accessibility.test.tsx +++ b/src/components/CwlEditor.accessibility.test.tsx @@ -1,8 +1,17 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CwlEditor } from './CwlEditor.js'; -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); describe('CwlEditor form accessibility metadata', () => { it('binds language, direction, labels, validation, and live prop updates', async () => { @@ -67,4 +76,42 @@ describe('CwlEditor form accessibility metadata', () => { expect(readOnlyEditor).not.toHaveAttribute('aria-errormessage'); }); }); + + it('does not invoke editing shortcuts while the editor is read-only', async () => { + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('https://example.com'); + + render( + , + ); + + const editor = await screen.findByRole('textbox', { name: 'Archived body' }); + fireEvent.keyDown(editor, { key: 'k', ctrlKey: true }); + fireEvent.keyDown(editor, { key: 'k', metaKey: true }); + + expect(prompt).not.toHaveBeenCalled(); + }); + + it.each([{ ctrlKey: true }, { metaKey: true }])('updates shortcut authority when editability changes: %j', async (modifier) => { + const prompt = vi.spyOn(window, 'prompt').mockReturnValue(null); + const { rerender } = render(); + const editor = await screen.findByRole('textbox', { name: 'Decision' }); + fireEvent.keyDown(editor, { key: 'k', ...modifier }); + expect(prompt).toHaveBeenCalledTimes(1); + + rerender(); + await waitFor(() => expect(editor).toHaveAttribute('aria-readonly', 'true')); + fireEvent.keyDown(editor, { key: 'k', ...modifier }); + expect(prompt).toHaveBeenCalledTimes(1); + expect(editor).toHaveTextContent('Retained decision'); + + rerender(); + await waitFor(() => expect(editor).toHaveAttribute('aria-readonly', 'false')); + fireEvent.keyDown(editor, { key: 'k', ...modifier }); + expect(prompt).toHaveBeenCalledTimes(2); + expect(screen.getByRole('textbox', { name: 'Decision' })).toBe(editor); + }); }); diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx index ebd1e85d..db9a3a8f 100644 --- a/src/components/EditorFrame.tsx +++ b/src/components/EditorFrame.tsx @@ -46,6 +46,7 @@ export function EditorFrame({ (event: KeyboardEvent) => { /* v8 ignore next -- keyboard events cannot reach an unmounted editor. */ if (!editor) return; + if (!editable) return; const modifier = event.metaKey || event.ctrlKey; if (modifier && event.key.toLowerCase() === 'k') { event.preventDefault(); @@ -66,7 +67,7 @@ export function EditorFrame({ } } }, - [editor], + [editable, editor], ); return ( diff --git a/tests/browser/specs/read-only-shortcut.browser.spec.ts b/tests/browser/specs/read-only-shortcut.browser.spec.ts new file mode 100644 index 00000000..3f7121a4 --- /dev/null +++ b/tests/browser/specs/read-only-shortcut.browser.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; + +for (const modifier of ['Control', 'Meta'] as const) { + test(`keeps ${modifier}+K aligned with live read-only state`, async ({ page }, testInfo) => { + const rejectedOrigins: string[] = []; + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.hostname === '127.0.0.1' && url.port === '4173') { + await route.continue(); + } else { + rejectedOrigins.push(url.origin); + await route.abort('blockedbyclient'); + } + }); + const prompts: string[] = []; + page.on('dialog', async (dialog) => { + prompts.push(dialog.message()); + await dialog.dismiss(); + }); + await page.goto('/tests/browser/input-harness.html?toolbar=1'); + const editor = page.getByRole('textbox', { name: 'Rich text editor' }); + await expect(editor).toHaveAttribute('contenteditable', 'true'); + await editor.click(); + await page.keyboard.insertText('Retained decision — 한글 日本語 Tiếng Việt'); + const originalEditor = await editor.elementHandle(); + expect(originalEditor).not.toBeNull(); + await page.keyboard.press(`${modifier}+k`); + await expect.poll(() => prompts.length).toBe(1); + await page.screenshot({ path: testInfo.outputPath(`${modifier}-editable.png`), fullPage: true }); + + await page.evaluate(() => window.inkspanInputHarness.setEditable(false)); + await expect(editor).toHaveAttribute('contenteditable', 'false'); + await expect(editor).toHaveAttribute('aria-readonly', 'true'); + // Read-only content is not focusable; explicitly exercise its bubbling handler. + const allowedDefault = await editor.evaluate((element, keyModifier) => + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'k', bubbles: true, cancelable: true, + ctrlKey: keyModifier === 'Control', metaKey: keyModifier === 'Meta', + })), modifier); + expect(allowedDefault).toBe(true); + expect(prompts).toEqual(['Link URL']); + await expect(editor).toHaveText('Retained decision — 한글 日本語 Tiếng Việt'); + await expect(page.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)', exact: true })).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath(`${modifier}-readonly.png`), fullPage: true }); + + await page.evaluate(() => window.inkspanInputHarness.setEditable(true)); + await expect(editor).toHaveAttribute('contenteditable', 'true'); + await editor.focus(); + await page.keyboard.press(`${modifier}+k`); + await expect.poll(() => prompts.length).toBe(2); + expect(prompts).toEqual(['Link URL', 'Link URL']); + await expect(editor).toHaveText('Retained decision — 한글 日本語 Tiếng Việt'); + expect(await originalEditor!.evaluate((element) => element.isConnected)).toBe(true); + await expect(page.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)', exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath(`${modifier}-reenabled.png`), fullPage: true }); + await page.waitForLoadState('networkidle'); + expect(rejectedOrigins).toEqual([]); + }); +}