diff --git a/e2e/specs/06-search-and-filters.e2e.ts b/e2e/specs/06-search-and-filters.e2e.ts
index 07abfac..63c3910 100644
--- a/e2e/specs/06-search-and-filters.e2e.ts
+++ b/e2e/specs/06-search-and-filters.e2e.ts
@@ -1,7 +1,7 @@
import { $, expect } from '@wdio/globals';
import { canvas } from '../pageobjects/canvas.page.js';
-import { cursorOf, reloadCanvas, testid } from '../support/app.js';
+import { blur, cursorOf, press, reloadCanvas, testid } from '../support/app.js';
import { bridge, draft, homeSpaceId } from '../support/bridge.js';
/**
@@ -113,6 +113,35 @@ describe('Search, filters and facets', () => {
expect(await $(testid('search-matched')).isExisting()).toBe(false);
});
+ /**
+ * The search, the tags and the languages were undone one at a time, each where it was
+ * set — three bands of the header. The count doubles as the way out of all three.
+ */
+ it('drops the search, the tag and the language in one click', async () => {
+ await canvas.search('Docker');
+ await canvas.toggleTag('ops');
+ await canvas.toggleLanguage('sh');
+
+ await $(testid('search-matched')).click();
+ await canvas.open();
+
+ expect(await $(testid('search-input')).getValue()).toBe('');
+ expect(await $(testid('search-matched')).isExisting()).toBe(false);
+ expect(await canvas.tagPill('ops').getAttribute('aria-pressed')).toBe('false');
+ expect(await canvas.languageChip('sh').getAttribute('aria-pressed')).toBe('false');
+ });
+
+ it('does the same on Escape, once there is no selection to clear', async () => {
+ await canvas.search('Docker');
+ // ⚠️ Focus has to leave the field: the canvas keyboard ignores a keystroke aimed at
+ // an input, which is what leaves Ctrl+K and typing alone.
+ await blur();
+ await press('Escape');
+ await canvas.open();
+
+ expect(await $(testid('search-matched')).isExisting()).toBe(false);
+ });
+
it('quotes the line that matched rather than the head of the body', async () => {
const spaceId = await homeSpaceId();
await bridge.createNote(
diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json
index a531da5..880f890 100644
--- a/src/app/core/services/i18n/translations/en.json
+++ b/src/app/core/services/i18n/translations/en.json
@@ -30,6 +30,7 @@
"loading": "Loading notes…",
"noResults": "No note matches this search.",
"searchMatched": "{{count}} result(s)",
+ "clearFilters": "Show everything",
"attachmentsCount": "{{count}} attachment(s)",
"placeholdersCount": "{{count}} field(s) to fill",
"selectNote": "Select note {{title}}",
@@ -350,7 +351,7 @@
"extendWithClick": "Extend the selection with a click",
"trash": "Send to the trash",
"undo": "Undo the last deletion",
- "clearSelection": "Clear the selection"
+ "clearSelection": "Clear the selection, then the filters"
},
"editor": {
"close": "Close the editor, saving",
diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json
index 4b74110..5b20926 100644
--- a/src/app/core/services/i18n/translations/fr.json
+++ b/src/app/core/services/i18n/translations/fr.json
@@ -30,6 +30,7 @@
"loading": "Chargement des notes…",
"noResults": "Aucune note ne correspond à cette recherche.",
"searchMatched": "{{count}} résultat(s)",
+ "clearFilters": "Tout afficher",
"attachmentsCount": "{{count}} pièce(s) jointe(s)",
"placeholdersCount": "{{count}} champ(s) à remplir",
"selectNote": "Sélectionner la note {{title}}",
@@ -350,7 +351,7 @@
"extendWithClick": "Étendre la sélection au clic",
"trash": "Mettre à la corbeille",
"undo": "Annuler la dernière suppression",
- "clearSelection": "Vider la sélection"
+ "clearSelection": "Annuler la sélection, puis les filtres"
},
"editor": {
"close": "Fermer l'éditeur en enregistrant",
diff --git a/src/app/core/state/notes-query.store.spec.ts b/src/app/core/state/notes-query.store.spec.ts
index c11ed00..24149d6 100644
--- a/src/app/core/state/notes-query.store.spec.ts
+++ b/src/app/core/state/notes-query.store.spec.ts
@@ -182,6 +182,83 @@ describe('NotesQueryStore', () => {
});
});
+ /**
+ * The search, the tags and the languages were each undone where they were set — three
+ * bands of the header, one gesture apiece. This is the way out in one.
+ */
+ describe('clearing the filters', () => {
+ it('drops the search, the tags and the languages together', async () => {
+ const { canvas, repository } = await createNotesHarness([createNote()]);
+ canvas.setSearchQuery('deploy');
+ canvas.toggleTag('urgent');
+ canvas.toggleLanguage('json');
+ // ⚠️ Waited for: the params are a `computed` feeding a `resource`, so three setters
+ // undone before it runs would collapse to no change at all and prove nothing.
+ await vi.waitFor(() => expect(repository.lastQuery?.search).toBe('deploy'));
+ const before = repository.queryCount;
+
+ canvas.clearFilters();
+ await awaitQuery(repository, before);
+
+ expect(repository.lastQuery?.search).toBe('');
+ expect(repository.lastQuery?.tags).toEqual([]);
+ expect(repository.lastQuery?.languages).toEqual([]);
+ // The field is what the user is looking at, and it has to look empty too.
+ expect(canvas.searchQuery()).toBe('');
+ });
+
+ /**
+ * ⚠️ Not through `setSearchQuery`: its debounce would leave the canvas filtered for
+ * another 150 ms after the user asked it not to be.
+ */
+ it('takes the search out of the query without waiting for the debounce', async () => {
+ const { canvas, repository } = await createNotesHarness([createNote()]);
+ canvas.setSearchQuery('deploy');
+ await vi.waitFor(() => expect(repository.lastQuery?.search).toBe('deploy'));
+ const before = repository.queryCount;
+
+ canvas.clearFilters();
+ await awaitQuery(repository, before);
+
+ expect(repository.lastQuery?.search).toBe('');
+ });
+
+ /**
+ * ⚠️ Clearing has to **cancel** the pending call, not merely set the signals past it.
+ * A keystroke from a moment ago is still on its way; it lands 150 ms later and puts
+ * the query back, so the canvas filters itself again with an empty field to explain
+ * it. Setting the two signals alone left exactly that.
+ *
+ * Real timers here, and a real wait: the bug needs the debounce to actually elapse,
+ * and faking it only proves the assertion ran before the timer did.
+ */
+ it('drops a keystroke still in flight when the filters are cleared', async () => {
+ const { canvas, repository } = await createNotesHarness([createNote()]);
+ const query = vi.spyOn(repository, 'query');
+
+ canvas.setSearchQuery('deploy');
+ canvas.clearFilters();
+ await new Promise((resolve) => setTimeout(resolve, SEARCH_DEBOUNCE_MS * 3));
+
+ const searched = query.mock.calls.map(([sent]) => sent.search);
+ expect(searched).not.toContain('deploy');
+ expect(canvas.searchQuery()).toBe('');
+ });
+
+ it('leaves the quick filter alone, which has a control of its own', async () => {
+ const { canvas, repository } = await createNotesHarness([createNote()]);
+ canvas.setFilter('pinned');
+ canvas.toggleTag('urgent');
+ const before = repository.queryCount;
+
+ canvas.clearFilters();
+ await awaitQuery(repository, before);
+
+ expect(repository.lastQuery?.filter).toBe('pinned');
+ expect(canvas.activeFilter()).toBe('pinned');
+ });
+ });
+
describe('search debounce', () => {
beforeEach(() => {
// Only Date and timers: faking requestAnimationFrame would hang the
diff --git a/src/app/core/state/notes-query.store.ts b/src/app/core/state/notes-query.store.ts
index 2e805fc..84dbaa3 100644
--- a/src/app/core/state/notes-query.store.ts
+++ b/src/app/core/state/notes-query.store.ts
@@ -189,6 +189,28 @@ export class NotesQueryStore {
this._selectedLanguages.update((languages) => toggled(languages, language));
}
+ /**
+ * Drops the search, the tags and the languages in one go — the three the back end
+ * counts as filtering, and the three that had to be undone one at a time where each
+ * was set.
+ *
+ * ⚠️ The quick filter is deliberately left alone. `is_filtering` in `notes::view` does
+ * not count it, so it is not what made this gesture appear; and it has a visible
+ * three-way control of its own with "All" in it, which is already the way out.
+ *
+ * ⚠️ The pending debounce is cancelled, **then** the two search signals are set. Setting
+ * them alone was not enough: a keystroke from a moment ago is still on its way, and it
+ * lands 150 ms later and puts the query back — the canvas filters itself again with an
+ * empty field to explain it.
+ */
+ clearFilters(): void {
+ this.commitSearch.cancel();
+ this._searchQuery.set('');
+ this._debouncedSearch.set('');
+ this._selectedTags.set(new Set());
+ this._selectedLanguages.set(new Set());
+ }
+
findVisible(id: string): Note | null {
for (const section of this.sections()) {
const found = section.notes.find((note) => note.id === id);
diff --git a/src/app/notes/header/search-box/search-box.component.html b/src/app/notes/header/search-box/search-box.component.html
index 815015e..1f041b9 100644
--- a/src/app/notes/header/search-box/search-box.component.html
+++ b/src/app/notes/header/search-box/search-box.component.html
@@ -13,9 +13,18 @@
/>
@if (matched() !== null) {
- {{
- 'notes.searchMatched' | transloco: { count: matched() }
- }}
+
+
} @else {
{{ shortcutHint }}
}
diff --git a/src/app/notes/header/search-box/search-box.component.scss b/src/app/notes/header/search-box/search-box.component.scss
index d2d94c4..d4b5482 100644
--- a/src/app/notes/header/search-box/search-box.component.scss
+++ b/src/app/notes/header/search-box/search-box.component.scss
@@ -40,8 +40,29 @@
// Takes the shortcut hint's place, and its alignment with it.
.count {
+ all: unset;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: 5px;
margin-left: auto;
white-space: nowrap;
font-size: 11px;
color: var(--text-2);
+ cursor: pointer;
+}
+
+.count:hover,
+.count:focus-visible {
+ color: var(--amber);
+}
+
+.count:focus-visible {
+ outline: 2px solid var(--amber);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.count-clear {
+ font-size: 10px;
}
diff --git a/src/app/notes/header/search-box/search-box.component.spec.ts b/src/app/notes/header/search-box/search-box.component.spec.ts
index 9200fcb..7f3e2f6 100644
--- a/src/app/notes/header/search-box/search-box.component.spec.ts
+++ b/src/app/notes/header/search-box/search-box.component.spec.ts
@@ -60,13 +60,16 @@ describe('SearchBoxComponent', () => {
* `NotesView.matched` decided one boolean and was never shown.
*/
describe('the count', () => {
+ function matchedText(): string {
+ const node = fixture.nativeElement.querySelector('[data-testid="search-matched"]');
+ return node.textContent.replace(/\s+/g, ' ').trim();
+ }
+
it('replaces the shortcut hint while something is being filtered', async () => {
fixture.componentRef.setInput('matched', 12);
await fixture.whenStable();
- expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]').textContent.trim()).toBe(
- '12 résultat(s)',
- );
+ expect(matchedText()).toBe('12 résultat(s) ✕');
expect(fixture.nativeElement.querySelector('.kbd')).toBeNull();
});
@@ -75,9 +78,7 @@ describe('SearchBoxComponent', () => {
fixture.componentRef.setInput('matched', 0);
await fixture.whenStable();
- expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]').textContent.trim()).toBe(
- '0 résultat(s)',
- );
+ expect(matchedText()).toBe('0 résultat(s) ✕');
});
it('shows the hint again when nothing is being filtered', async () => {
@@ -87,6 +88,34 @@ describe('SearchBoxComponent', () => {
expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]')).toBeNull();
expect(fixture.nativeElement.querySelector('.kbd')).not.toBeNull();
});
+
+ /**
+ * The count doubles as the way out. ⚠️ `preventDefault` matters: the field is inside
+ * the `