mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-07-27 17:28:34 +00:00
Reimplement editor Tab handling with accessibility safeguards (#6813)
The primary goal is to balance having the editor work as expected by developers (with Tab key affecting indentation) while also not impeding keyboard navigation.
* Tab indents, Shift+Tab unindents, but only when that indent would be valid. E.g. moving existing list items down or up one level.
* Indenting a selection always works.
* When an "invalid" indent is attempted, nothing happens and a toast is shown with a hint to press again to leave the editor.
* Attempting the same action again allows the textarea lose focus by allowing the browser's default key handler.
* Pressing Esc also loses focus immediately.
* No tab handling happens until the text editor has been interacted with (other than just having been focused).
* Changing indentation in block quotes adds or removes quote levels instead.
Screenshot of the toast being shown:
a6287d29
-4ce0-4977-aae8-ef1aff2ac89f
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6813
Reviewed-by: Otto <otto@codeberg.org>
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Co-authored-by: Danko Aleksejevs <danko@very.lv>
Co-committed-by: Danko Aleksejevs <danko@very.lv>
This commit is contained in:
parent
8b93f41aaa
commit
d483dc674a
8 changed files with 278 additions and 30 deletions
|
@ -39,7 +39,7 @@ test('Markdown image preview behaviour', async ({page}, workerInfo) => {
|
|||
await save_visual(page);
|
||||
});
|
||||
|
||||
test('Markdown indentation', async ({page}) => {
|
||||
test('Markdown indentation via toolbar', async ({page}) => {
|
||||
const initText = `* first\n* second\n* third\n* last`;
|
||||
|
||||
const response = await page.goto('/user2/repo1/issues/new');
|
||||
|
@ -50,7 +50,6 @@ test('Markdown indentation', async ({page}) => {
|
|||
const indent = page.locator('button[data-md-action="indent"]');
|
||||
const unindent = page.locator('button[data-md-action="unindent"]');
|
||||
await textarea.fill(initText);
|
||||
await textarea.click(); // Tab handling is disabled until pointer event or input.
|
||||
|
||||
// Indent, then unindent first line
|
||||
await textarea.focus();
|
||||
|
@ -109,6 +108,146 @@ test('Markdown indentation', async ({page}) => {
|
|||
await expect(textarea).toHaveValue(initText);
|
||||
});
|
||||
|
||||
test('markdown indentation with Tab', async ({page}) => {
|
||||
const initText = `* first\n* second\n* third\n* last`;
|
||||
|
||||
const response = await page.goto('/user2/repo1/issues/new');
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
const textarea = page.locator('textarea[name=content]');
|
||||
const toast = page.locator('.toastify');
|
||||
const tab = ' ';
|
||||
|
||||
await textarea.fill(initText);
|
||||
|
||||
await textarea.click(); // Tab handling is disabled until pointer event or input.
|
||||
|
||||
// Indent, then unindent first line
|
||||
await textarea.focus();
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(0, 0));
|
||||
await textarea.press('Tab');
|
||||
await expect(textarea).toHaveValue(`${tab}* first\n* second\n* third\n* last`);
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).toHaveValue(initText);
|
||||
|
||||
// Attempt unindent again, ensure focus is not immediately lost and toast is shown, but then focus is lost on next attempt.
|
||||
await expect(toast).toBeHidden(); // toast should not already be there
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).toBeFocused();
|
||||
await expect(toast).toBeVisible();
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).not.toBeFocused();
|
||||
|
||||
// Indent lines 2-4
|
||||
await textarea.click();
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('\n') + 1, it.value.length));
|
||||
await textarea.press('Tab');
|
||||
await expect(textarea).toHaveValue(`* first\n${tab}* second\n${tab}* third\n${tab}* last`);
|
||||
|
||||
// Indent second line while in whitespace, then unindent.
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf(' * third'), it.value.indexOf(' * third')));
|
||||
await textarea.press('Tab');
|
||||
await expect(textarea).toHaveValue(`* first\n${tab}* second\n${tab}${tab}* third\n${tab}* last`);
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).toHaveValue(`* first\n${tab}* second\n${tab}* third\n${tab}* last`);
|
||||
|
||||
// Select all and unindent, then lose focus.
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.select());
|
||||
await textarea.press('Shift+Tab'); // Everything is unindented.
|
||||
await expect(textarea).toHaveValue(initText);
|
||||
await textarea.press('Shift+Tab'); // Valid, but nothing happens -> switch to "about to lose focus" state.
|
||||
await expect(textarea).toBeFocused();
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).not.toBeFocused();
|
||||
|
||||
// Attempt the same with cursor within list element body.
|
||||
await textarea.focus();
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(0, 0));
|
||||
await textarea.press('ArrowRight');
|
||||
await textarea.press('ArrowRight');
|
||||
await textarea.press('Tab');
|
||||
// Whole line should be indented.
|
||||
await expect(textarea).toHaveValue(`${tab}* first\n* second\n* third\n* last`);
|
||||
await textarea.press('Shift+Tab');
|
||||
|
||||
// Subsequently, select a chunk of 2nd and 3rd line and indent both, preserving the cursor position in relation to text
|
||||
const line3 = `* first\n* second\n${tab}* third\n* last`;
|
||||
const lines23 = `* first\n${tab}* second\n${tab}${tab}* third\n* last`;
|
||||
await textarea.focus();
|
||||
await textarea.fill(line3);
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('hird')));
|
||||
await textarea.press('Tab');
|
||||
await expect(textarea).toHaveValue(lines23);
|
||||
await expect(textarea).toHaveJSProperty('selectionStart', lines23.indexOf('cond'));
|
||||
await expect(textarea).toHaveJSProperty('selectionEnd', lines23.indexOf('hird'));
|
||||
|
||||
// Then unindent twice, erasing all indents.
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).toHaveValue(line3);
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).toHaveValue(initText);
|
||||
|
||||
// Check that partial indents are cleared
|
||||
await textarea.focus();
|
||||
await textarea.fill(initText);
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('* second'), it.value.indexOf('* second')));
|
||||
await textarea.pressSequentially(' ');
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).toHaveValue(initText);
|
||||
});
|
||||
|
||||
test('markdown block quote indentation', async ({page}) => {
|
||||
const initText = `> first\n> second\n> third\n> last`;
|
||||
|
||||
const response = await page.goto('/user2/repo1/issues/new');
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
const textarea = page.locator('textarea[name=content]');
|
||||
const toast = page.locator('.toastify');
|
||||
|
||||
await textarea.fill(initText);
|
||||
|
||||
await textarea.click(); // Tab handling is disabled until pointer event or input.
|
||||
|
||||
// Indent, then unindent first line twice (quotes can quote quotes!)
|
||||
await textarea.focus();
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(0, 0));
|
||||
await textarea.press('Tab');
|
||||
await expect(textarea).toHaveValue(`> > first\n> second\n> third\n> last`);
|
||||
await textarea.press('Tab');
|
||||
await expect(textarea).toHaveValue(`> > > first\n> second\n> third\n> last`);
|
||||
await textarea.press('Shift+Tab');
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).toHaveValue(initText);
|
||||
|
||||
// Attempt unindent again.
|
||||
await expect(toast).toBeHidden(); // toast should not already be there
|
||||
await textarea.press('Shift+Tab');
|
||||
// Nothing happens - quote should not stop being a quote
|
||||
await expect(textarea).toHaveValue(initText);
|
||||
// Focus is not immediately lost and toast is shown,
|
||||
await expect(textarea).toBeFocused();
|
||||
await expect(toast).toBeVisible();
|
||||
// Focus is lost on next attempt,
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).not.toBeFocused();
|
||||
|
||||
// Indent lines 2-4
|
||||
await textarea.click();
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('\n') + 1, it.value.length));
|
||||
await textarea.press('Tab');
|
||||
await expect(textarea).toHaveValue(`> first\n> > second\n> > third\n> > last`);
|
||||
|
||||
// Select all and unindent, then lose focus.
|
||||
await textarea.evaluate((it:HTMLTextAreaElement) => it.select());
|
||||
await textarea.press('Shift+Tab'); // Everything is unindented.
|
||||
await expect(textarea).toHaveValue(initText);
|
||||
await textarea.press('Shift+Tab'); // Valid, but nothing happens -> switch to "about to lose focus" state.
|
||||
await expect(textarea).toBeFocused();
|
||||
await textarea.press('Shift+Tab');
|
||||
await expect(textarea).not.toBeFocused();
|
||||
});
|
||||
|
||||
test('Markdown list continuation', async ({page}) => {
|
||||
const initText = `* first\n* second`;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue