Skip to content

Commit 3a765bc

Browse files
BeSovereignclaude
andauthored
fix(#403): No way to clear a selection: no Esc, no clear button, no select-all; Upload stay
Esc on a row or the list clears a non-empty selection (onActionKey in itemActions.ts); the selection bar (desktop and phone sheet) gets a borderless 'Clear selection' icon button; the shortcuts dialog lists Esc; i18n added in all three locales. The #403 E2E test now deletes its folder afterwards so the shared home root stays under the list's 200-entry render limit, which had made the collision spec time out on mobile. Refs #403 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f74d4c3 commit 3a765bc

9 files changed

Lines changed: 85 additions & 2 deletions

File tree

‎DECISIONS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,3 +1388,11 @@ Question: a password-protected link answered `info` with the item's name and typ
13881388
Decision: while locked, `GET /s/<token>/info` is `{ locked: true }` and nothing else; `name` and `type` are optional in `PublicLinkInfo` and come only once the link is unlocked, as before. The page keeps the header with the app's name, as on every visitor page; the kind line says "Shared link" instead of "Shared file"/"Shared folder", the heading is "Protected link" (`link.page.lockedTitle`), and the existing line "This link is protected by a password." stands above the password field. A link without a password is unlocked at once and is unchanged.
13891389

13901390
Why: the owner set the password to keep the item private, and what it is called and whether it is a file or a folder is part of that; file requests already answer only the owner-written label while locked. The link's label stays owner-only: it was written for the owner's list ("For the accountant"), not for visitors, and showing it would be a new disclosure nobody asked for (YAGNI).
1391+
1392+
## 2026-10-20: One step clears a selection: Esc and a clear button (#403)
1393+
1394+
Question: after Ctrl+A (or "New folder", which selects what it creates) nothing cleared the selection but unticking every row, and on the desktop the bar keeps Upload hidden meanwhile. Which ways out, and do a select-all checkbox in the list header or Ctrl+A as a toggle come with them?
1395+
1396+
Decision: Esc on a row or tile clears a non-empty selection (`onActionKey`; open menus and dialogs stop the key first), and the selection bar has a borderless icon button "Clear selection" (`icon-close`, aria-label) right after the count, on the desktop bar and on the phone's bottom bar alike. The shortcuts dialog lists "Esc: clear the selection". No header checkbox, Ctrl+A stays select-all, and a click on the empty area does not clear.
1397+
1398+
Why: the issue's expected behaviour asks for exactly these two ways; the clear button sits beside the count it resets, where both a mouse and a finger look for it. A header checkbox and a toggling Ctrl+A would be a second and third way to the same state (YAGNI), and clearing on an empty-area click would fight the empty area's own menu (#340).

‎e2e/polish.spec.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,10 @@ test('in the trash and in a read-only share, no menu offers rename, move or uplo
280280
await row(member, 'notes.txt').locator('input[type=checkbox]').check();
281281
const bar = member.getByRole('group', { name: 'Actions for the selection' });
282282
await expect(bar.getByRole('link', { name: 'Download' })).toBeVisible();
283-
await expect(bar.getByRole('button')).toHaveText(['Copy to…']); // copying out only reads (#339)
283+
// Copying out only reads (#339); clearing the selection (#403) changes nothing in the share.
284+
await expect(bar.getByRole('button')).toHaveCount(2);
285+
await expect(bar.getByRole('button').first()).toHaveAccessibleName('Clear selection');
286+
await expect(bar.getByRole('button').last()).toHaveText('Copy to…');
284287
await member.context().close();
285288
});
286289

‎e2e/selection-bar.spec.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,50 @@ test('#383 in a pane of two the German selection bar wraps under the title, not
177177
await put('/api/v1/settings', { locale: null, theme: 'system' });
178178
}
179179
});
180+
181+
// #403: one step clears a selection: Esc on a row, or the bar's "Clear selection" button; then Upload is back.
182+
test('#403 Esc and the bar\'s clear button clear the selection and bring Upload back', async ({ page }, info) => {
183+
await page.goto('/');
184+
await expect(page.getByRole('navigation', { name: /Pfad|Path/ })).toBeVisible();
185+
const { csrfToken } = await (await page.request.get('/api/v1/me')).json();
186+
const headers = { 'x-csrf-token': csrfToken, origin: new URL(page.url()).origin };
187+
const dir = `selclear403-${tag(info)}`;
188+
expect((await page.request.post('/api/v1/sources/home/mkdir', { headers, data: { path: `/${dir}` } })).status()).toBe(201);
189+
for (const name of ['a.txt', 'b.txt', 'c.txt']) {
190+
const r = await page.request.put(`/api/v1/sources/home/content?path=${encodeURIComponent(`/${dir}/${name}`)}&onCollision=fail`,
191+
{ headers: { ...headers, 'content-type': 'application/octet-stream' }, data: Buffer.from(name) });
192+
expect(r.status()).toBe(201);
193+
}
194+
await page.goto(`/home/${encodeURIComponent(dir)}`);
195+
await expect(page.locator('tbody tr')).toHaveCount(3);
196+
const upload = page.getByRole('button', { name: /^(Hochladen|Upload)$/ });
197+
await expect(upload).toBeVisible();
198+
199+
await row(page, 'b.txt').focus();
200+
await page.keyboard.press('ControlOrMeta+a');
201+
await expect(bar(page).locator('.selection-count')).toHaveText(/^3 (ausgewählt|selected)$/);
202+
if (!isMobile(info)) await expect(upload).toBeHidden(); // on the desktop the bar takes the toolbar's place
203+
await page.keyboard.press('Escape');
204+
await expect(bar(page)).toHaveCount(0);
205+
await expect(upload).toBeVisible();
206+
await expect(row(page, 'b.txt')).toBeFocused();
207+
208+
await page.keyboard.press('ControlOrMeta+a');
209+
await expect(bar(page).locator('.selection-count')).toHaveText(/^3 (ausgewählt|selected)$/);
210+
const clear = bar(page).getByRole('button', { name: /^(Auswahl aufheben|Clear selection)$/ });
211+
await expect(clear).toBeVisible();
212+
await clear.click();
213+
await expect(bar(page)).toHaveCount(0);
214+
await expect(upload).toBeVisible();
215+
for (const name of ['a.txt', 'b.txt', 'c.txt']) await expect(row(page, name).getByRole('checkbox')).not.toBeChecked();
216+
217+
// The shortcuts dialog names the key.
218+
await row(page, 'b.txt').focus();
219+
await page.keyboard.press('?');
220+
const dialog = page.getByRole('dialog', { name: /^(Tastenkürzel|Keyboard shortcuts)$/ });
221+
await expect(dialog.getByRole('row', { name: /^Esc (Auswahl aufheben|Clear the selection)$/ })).toBeVisible();
222+
223+
// Removed again so the home root stays within the list's window for other specs (it windows from 200 entries, #225).
224+
const del = await page.request.post('/api/v1/sources/home/delete', { data: { paths: [`/${dir}`] }, headers });
225+
expect(del.status(), await del.text()).toBe(204);
226+
});

‎web/src/components/FolderPane.vue‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,20 @@ defineExpose({
826826
v-if="selectedEntries.length"
827827
class="selection-count"
828828
>{{ $t('actions.selected', { count: selectedEntries.length }) }}</span>
829+
<!-- One step out of a selection (#403), on the desktop bar and on the phone sheet alike. -->
830+
<button
831+
v-if="selectedEntries.length"
832+
type="button"
833+
class="icon-button clear-selection"
834+
:aria-label="$t('actions.clearSelection')"
835+
:title="$t('actions.clearSelection')"
836+
@click="selected = []"
837+
>
838+
<span
839+
class="icon icon-close"
840+
aria-hidden="true"
841+
/>
842+
</button>
829843
<template v-if="selectedEntries.length">
830844
<a
831845
class="button"

‎web/src/components/ShortcutsDialog.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const emit = defineEmits<{ close: [] }>();
1212
const { t } = useI18n();
1313
const layout = useLayoutStore();
1414
const SHORTCUTS = computed(() => ['arrows', 'galleryArrows', 'homeEnd', 'open', 'select', 'selectAll', 'rename', 'trash', 'cut', 'copy',
15-
'paste', 'moveTo', 'copyTo', 'newFolder', 'menu', 'close', 'history', 'help',
15+
'paste', 'moveTo', 'copyTo', 'newFolder', 'menu', 'clearSelection', 'close', 'history', 'help',
1616
...(layout.dualPane ? ['switchPane', 'copyOther', 'moveOther'] : []),
1717
...(layout.tabs ? ['newTab', 'closeTab', 'openTab'] : [])]);
1818
</script>

‎web/src/i18n/de-du.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@
533533
"pasteMove": "Hierher verschieben ({count})",
534534
"pasteCopy": "Hierher kopieren ({count})",
535535
"clearClipboard": "Verschieben oder Kopieren abbrechen",
536+
"clearSelection": "Auswahl aufheben",
536537
"trash": "In den Papierkorb",
537538
"favourite": "Favorit",
538539
"copyToOther": "In den anderen Bereich kopieren",
@@ -1199,6 +1200,7 @@
11991200
"moveTo": "Umschalt + M",
12001201
"copyTo": "Umschalt + C",
12011202
"menu": "Umschalt + F10",
1203+
"clearSelection": "Esc",
12021204
"close": "Esc",
12031205
"history": "Alt + ← / →",
12041206
"help": "?",
@@ -1225,6 +1227,7 @@
12251227
"moveTo": "Auswahl in einen gewählten Ordner verschieben",
12261228
"copyTo": "Auswahl in einen gewählten Ordner kopieren",
12271229
"menu": "Aktionsmenü des Eintrags öffnen (ohne Eintrag im Fokus das des Ordners)",
1230+
"clearSelection": "Auswahl aufheben",
12281231
"close": "Dialog oder Menü schließen",
12291232
"history": "Im Ordnerverlauf zurück oder vor, auch mit den Seitentasten der Maus",
12301233
"help": "Diese Hilfe anzeigen",

‎web/src/i18n/de-sie.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@
533533
"pasteMove": "Hierher verschieben ({count})",
534534
"pasteCopy": "Hierher kopieren ({count})",
535535
"clearClipboard": "Verschieben oder Kopieren abbrechen",
536+
"clearSelection": "Auswahl aufheben",
536537
"trash": "In den Papierkorb",
537538
"favourite": "Favorit",
538539
"copyToOther": "In den anderen Bereich kopieren",
@@ -1199,6 +1200,7 @@
11991200
"moveTo": "Umschalt + M",
12001201
"copyTo": "Umschalt + C",
12011202
"menu": "Umschalt + F10",
1203+
"clearSelection": "Esc",
12021204
"close": "Esc",
12031205
"history": "Alt + ← / →",
12041206
"help": "?",
@@ -1225,6 +1227,7 @@
12251227
"moveTo": "Auswahl in einen gewählten Ordner verschieben",
12261228
"copyTo": "Auswahl in einen gewählten Ordner kopieren",
12271229
"menu": "Aktionsmenü des Eintrags öffnen (ohne Eintrag im Fokus das des Ordners)",
1230+
"clearSelection": "Auswahl aufheben",
12281231
"close": "Dialog oder Menü schließen",
12291232
"history": "Im Ordnerverlauf zurück oder vor, auch mit den Seitentasten der Maus",
12301233
"help": "Diese Hilfe anzeigen",

‎web/src/i18n/en.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@
533533
"pasteMove": "Move here ({count})",
534534
"pasteCopy": "Copy here ({count})",
535535
"clearClipboard": "Cancel moving or copying",
536+
"clearSelection": "Clear selection",
536537
"trash": "Move to trash",
537538
"favourite": "Favourite",
538539
"copyToOther": "Copy to other pane",
@@ -1199,6 +1200,7 @@
11991200
"moveTo": "Shift + M",
12001201
"copyTo": "Shift + C",
12011202
"menu": "Shift + F10",
1203+
"clearSelection": "Esc",
12021204
"close": "Esc",
12031205
"history": "Alt + ← / →",
12041206
"help": "?",
@@ -1225,6 +1227,7 @@
12251227
"moveTo": "Move the selection into a folder you pick",
12261228
"copyTo": "Copy the selection into a folder you pick",
12271229
"menu": "Open the item menu (with no item focused, the folder’s)",
1230+
"clearSelection": "Clear the selection",
12281231
"close": "Close the dialog or menu",
12291232
"history": "Back or forward in the folder history, also with the mouse’s side buttons",
12301233
"help": "Show this help",

‎web/src/itemActions.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export function useItemActions(props: ItemProps, emit: Emits,
8989
else if (props.editable && e.shiftKey && !mod && !e.altKey && key === 'm' && handled()) emit.action('moveTo', targets(i));
9090
else if (e.shiftKey && !mod && !e.altKey && key === 'c' && handled()) emit.action('copyTo', targets(i));
9191
else if (mod && key === 'a' && handled()) emit.select(props.entries.map((x) => x.path));
92+
// Esc clears the selection (#403); open menus and dialogs stop it before it gets here.
93+
else if (key === 'Escape' && chosen.value.size > 0 && handled()) emit.select([]);
9294
else if ((key === 'ContextMenu' || (key === 'F10' && e.shiftKey)) && handled()) ui.showMenu(i);
9395
else if (key === 'Enter' && handled()) emit.open(entry); // a folder opens, a file opens in the preview
9496
else return false;

0 commit comments

Comments
 (0)