diff --git a/package.json b/package.json
index aa95fe20..2ef9d959 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.19.4",
+ "version": "0.19.5",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/src/css/style.css b/src/css/style.css
index 6c13ca5e..a41b2c13 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -193,6 +193,7 @@ html {
--floating-back-button-z-index: 3;
--reply-prompt-z-index: 4;
--message-input-z-index: 4;
+ --sidebar-search-z-index: 4;
--reaction-palette-z-index: 5;
--toast-z-index: 5;
--fab-z-index: 6;
@@ -391,6 +392,7 @@ video {
position: sticky;
top: 0;
align-self: flex-start;
+ min-height: 100dvh;
max-height: 100dvh;
overflow-y: auto;
}
@@ -5990,6 +5992,92 @@ button.profile-list-item-button.is-disabled {
border-bottom: none;
}
+sidebar-search {
+ display: none;
+}
+
+@media (min-width: 1100px) {
+ sidebar-search {
+ display: block;
+ }
+}
+
+.sidebar-search {
+ position: relative;
+ z-index: var(--sidebar-search-z-index);
+ margin: 16px 16px 0 16px;
+}
+
+.sidebar-search-input-container {
+ position: relative;
+ display: flex;
+}
+
+.sidebar-search-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 36px 10px 42px;
+ border: var(--hair) solid var(--post-border-color);
+ border-radius: var(--text-input-border-radius);
+ background: var(--text-input-background-color);
+ color: var(--text-color);
+ font-size: 16px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.sidebar-search-input::-webkit-search-cancel-button {
+ display: none;
+}
+
+@media (hover: hover) {
+ .sidebar-search-input:hover {
+ background: var(--post-hover-color);
+ }
+}
+
+.sidebar-search-input-container .search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 20px;
+ height: 20px;
+ color: var(--text-color-muted);
+ pointer-events: none;
+}
+
+.sidebar-search-clear-button {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 22px;
+ height: 22px;
+ background: none;
+}
+
+.sidebar-search-typeahead {
+ position: absolute;
+ z-index: 2;
+ left: 0;
+ right: 0;
+ top: calc(100% + 4px);
+ overflow: hidden;
+ border: var(--hair) solid var(--post-border-color);
+ border-radius: 12px;
+ background: var(--background-color);
+ box-shadow: var(--shadow-color-light);
+}
+
+.sidebar-search-typeahead-row {
+ padding-top: 10px;
+ padding-left: 8px;
+ padding-right: 8px;
+ padding-bottom: 8px;
+ border-bottom: none;
+}
+
.feeds-list {
display: flex;
flex-direction: column;
diff --git a/src/js/components/sidebar-search.js b/src/js/components/sidebar-search.js
new file mode 100644
index 00000000..39a31d2e
--- /dev/null
+++ b/src/js/components/sidebar-search.js
@@ -0,0 +1,223 @@
+import { html, render } from "/js/lib/lit-html.js";
+import { Component } from "/js/components/component.js";
+import { Signal, effect } from "/js/signals.js";
+import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js";
+import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js";
+import { avatarTemplate } from "/js/templates/avatar.template.js";
+import { getDisplayName } from "/js/dataHelpers.js";
+import { linkToProfile } from "/js/navigation.js";
+import "/js/components/container-link.js";
+
+const TYPEAHEAD_LIMIT = 8;
+
+function typeaheadTemplate({ query, profiles, onCommit, onSelectProfile }) {
+ return html`
`;
+}
+
+function sidebarSearchTemplate({
+ inputValue,
+ showTypeahead,
+ profiles,
+ onInput,
+ onKeydown,
+ onFocus,
+ onBlur,
+ onClear,
+ onCommit,
+ onSelectProfile,
+}) {
+ return html``;
+}
+
+class SidebarSearch extends Component {
+ connectedCallback() {
+ if (this.initialized) return;
+ this.initialized = true;
+ if (!this.dataLayer) {
+ throw new Error("sidebar-search requires a dataLayer property");
+ }
+ this.$inputValue = new Signal.State("");
+ this.$showTypeahead = new Signal.State(false);
+ this._disposers = [effect(() => this.render())];
+ }
+
+ disconnectedCallback() {
+ if (!this.initialized) return;
+ this._disposers?.forEach((dispose) => dispose());
+ this._disposers = null;
+ this.initialized = false;
+ }
+
+ render() {
+ const inputValue = this.$inputValue.get();
+ const showTypeahead = this.$showTypeahead.get();
+ const profiles = showTypeahead
+ ? this.dataLayer.derived.$sidebarSearchTypeaheadResults.get()
+ : null;
+ render(
+ sidebarSearchTemplate({
+ inputValue,
+ showTypeahead,
+ profiles,
+ onInput: (value) => this.handleInput(value),
+ onKeydown: (event) => this.handleKeydown(event),
+ onFocus: () => this.handleFocus(),
+ onBlur: () => this.$showTypeahead.set(false),
+ onClear: () => this.handleClear(),
+ onCommit: () => this.commitSearch(),
+ onSelectProfile: (did) => this.handleSelectProfile(did),
+ }),
+ this,
+ );
+ }
+
+ loadTypeahead(query) {
+ this.dataLayer.requests
+ .loadSidebarSearchTypeahead(query, { limit: TYPEAHEAD_LIMIT })
+ .catch((error) => console.warn("Typeahead search failed", error));
+ }
+
+ handleInput(value) {
+ this.$inputValue.set(value);
+ const trimmed = value.trim();
+ if (!trimmed) {
+ this.$showTypeahead.set(false);
+ this.loadTypeahead("");
+ return;
+ }
+ this.$showTypeahead.set(true);
+ this.loadTypeahead(trimmed);
+ }
+
+ handleFocus() {
+ if (this.$inputValue.get().trim()) {
+ this.$showTypeahead.set(true);
+ }
+ }
+
+ handleKeydown(event) {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ this.commitSearch();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ this.handleClear();
+ }
+ }
+
+ reset() {
+ this.$inputValue.set("");
+ this.$showTypeahead.set(false);
+ this.loadTypeahead("");
+ }
+
+ handleClear() {
+ this.reset();
+ this.querySelector(".sidebar-search-input")?.focus();
+ }
+
+ handleSelectProfile(did) {
+ if (this.isAuthenticated) {
+ this.dataLayer.mutations.addRecentSearchProfile(did).catch(console.warn);
+ }
+ this.reset();
+ this.querySelector(".sidebar-search-input")?.blur();
+ }
+
+ commitSearch() {
+ const query = this.$inputValue.get().trim();
+ if (!query) return;
+ if (this.isAuthenticated) {
+ this.dataLayer.mutations.addRecentSearch(query).catch(console.warn);
+ }
+ this.reset();
+ this.querySelector(".sidebar-search-input")?.blur();
+ const params = new URLSearchParams();
+ params.set("q", query);
+ window.router.go(`/search?${params.toString()}`);
+ }
+}
+
+SidebarSearch.register();
diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js
index 594ea7b8..92c2faf8 100644
--- a/src/js/dataLayer/dataStore.js
+++ b/src/js/dataLayer/dataStore.js
@@ -10,6 +10,7 @@ export class DataStore extends ReactiveStore {
this.$profileSearchResults = new Signal.State(null);
this.$chatRecipientSearchResults = new Signal.State(null);
this.$searchTypeaheadResults = new Signal.State(null);
+ this.$sidebarSearchTypeaheadResults = new Signal.State(null);
this.$feedSearchResults = new Signal.State(null);
this.$notifications = new Signal.State(null);
this.$mentionNotifications = new Signal.State(null);
@@ -24,6 +25,7 @@ export class DataStore extends ReactiveStore {
this.$latestProfileSearchRequestTime = new Signal.State(null);
this.$latestChatRecipientSearchRequestTime = new Signal.State(null);
this.$latestSearchTypeaheadRequestTime = new Signal.State(null);
+ this.$latestSidebarSearchTypeaheadRequestTime = new Signal.State(null);
this.$latestFeedSearchRequestTime = new Signal.State(null);
this.$trends = new Signal.State(null);
this.$postSearchResultsTop = new Signal.State(null);
diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js
index 6c8960fb..ebeaa5cf 100644
--- a/src/js/dataLayer/derived.js
+++ b/src/js/dataLayer/derived.js
@@ -312,6 +312,11 @@ export class Derived extends ReactiveStore {
if (!data) return null;
return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did));
});
+ this.$sidebarSearchTypeaheadResults = new Signal.Computed(() => {
+ const data = this.dataStore.$sidebarSearchTypeaheadResults.get();
+ if (!data) return null;
+ return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did));
+ });
this.$recentSearchTerms = new Signal.Computed(() => {
const preferences = this.$preferences.get();
if (!preferences) return [];
diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js
index c0485678..e3ef5c26 100644
--- a/src/js/dataLayer/requests.js
+++ b/src/js/dataLayer/requests.js
@@ -203,6 +203,10 @@ export class Requests {
);
this.enableStatus(this.loadChatRecipientSearch, "loadChatRecipientSearch");
this.enableStatus(this.loadSearchTypeahead, "loadSearchTypeahead");
+ this.enableStatus(
+ this.loadSidebarSearchTypeahead,
+ "loadSidebarSearchTypeahead",
+ );
this.enableStatus(
this.loadPostSearchTop,
(query) => "loadPostSearchTop-" + query,
@@ -648,50 +652,50 @@ export class Requests {
}
}
- async loadChatRecipientSearch(query, { limit = 12 } = {}) {
+ async _loadProfileTypeahead(query, { limit, $results, $latestRequestTime }) {
if (!query) {
// Invalidate in-flight searches so they can't repopulate cleared results
- this.dataStore.$latestChatRecipientSearchRequestTime.set(null);
- this.dataStore.$chatRecipientSearchResults.set(null);
+ $latestRequestTime.set(null);
+ $results.set(null);
return;
}
const labelers = this.requireLabelers();
const requestTime = Date.now();
- this.dataStore.$latestChatRecipientSearchRequestTime.set(requestTime);
+ $latestRequestTime.set(requestTime);
const searchData = await this.api.searchProfilesTypeahead(query, {
limit,
labelers,
});
- if (
- requestTime !== this.dataStore.$latestChatRecipientSearchRequestTime.get()
- ) {
+ if (requestTime !== $latestRequestTime.get()) {
return;
}
this.dataStore.setProfiles(searchData.actors);
- this.dataStore.$chatRecipientSearchResults.set(searchData);
+ $results.set(searchData);
+ }
+
+ async loadChatRecipientSearch(query, { limit = 12 } = {}) {
+ await this._loadProfileTypeahead(query, {
+ limit,
+ $results: this.dataStore.$chatRecipientSearchResults,
+ $latestRequestTime: this.dataStore.$latestChatRecipientSearchRequestTime,
+ });
}
async loadSearchTypeahead(query, { limit = 8 } = {}) {
- if (!query) {
- // Invalidate in-flight searches so they can't repopulate cleared results
- this.dataStore.$latestSearchTypeaheadRequestTime.set(null);
- this.dataStore.$searchTypeaheadResults.set(null);
- return;
- }
- const labelers = this.requireLabelers();
- const requestTime = Date.now();
- this.dataStore.$latestSearchTypeaheadRequestTime.set(requestTime);
- const searchData = await this.api.searchProfilesTypeahead(query, {
+ await this._loadProfileTypeahead(query, {
limit,
- labelers,
+ $results: this.dataStore.$searchTypeaheadResults,
+ $latestRequestTime: this.dataStore.$latestSearchTypeaheadRequestTime,
+ });
+ }
+
+ async loadSidebarSearchTypeahead(query, { limit = 8 } = {}) {
+ await this._loadProfileTypeahead(query, {
+ limit,
+ $results: this.dataStore.$sidebarSearchTypeaheadResults,
+ $latestRequestTime:
+ this.dataStore.$latestSidebarSearchTypeaheadRequestTime,
});
- if (
- requestTime !== this.dataStore.$latestSearchTypeaheadRequestTime.get()
- ) {
- return;
- }
- this.dataStore.setProfiles(searchData.actors);
- this.dataStore.$searchTypeaheadResults.set(searchData);
}
async loadPostSearchTop(query, { limit = 25, cursor = "" } = {}) {
diff --git a/src/js/mainLayout.js b/src/js/mainLayout.js
index f2ee2821..5a9a288b 100644
--- a/src/js/mainLayout.js
+++ b/src/js/mainLayout.js
@@ -52,6 +52,7 @@ function pluginPreviewBannerTemplate({ plugins }) {
import { Layout } from "/js/router.js";
import "/js/components/animated-sidebar.js";
import "/js/components/trending-pane.js";
+import "/js/components/sidebar-search.js";
export function mainLayoutTemplate({
isAuthenticated = true,
@@ -97,6 +98,12 @@ export function mainLayoutTemplate({
${children}
+ ${activeNavItem === "search"
+ ? ""
+ : html``}
diff --git a/src/js/views/search.view.js b/src/js/views/search.view.js
index b7be4ed7..04615302 100644
--- a/src/js/views/search.view.js
+++ b/src/js/views/search.view.js
@@ -286,7 +286,7 @@ export default async function searchView({
@click=${() => onCommit()}
>
${searchIconTemplate()}
- Search for "${query}"
+ ${query}
${profiles === null
? html`
diff --git a/tests/e2e/specs/concerns/sidebarSearch.test.js b/tests/e2e/specs/concerns/sidebarSearch.test.js
new file mode 100644
index 00000000..13665d0a
--- /dev/null
+++ b/tests/e2e/specs/concerns/sidebarSearch.test.js
@@ -0,0 +1,89 @@
+import { test, expect } from "../../base.js";
+import { login } from "../../helpers.js";
+import { MockServer } from "../../mockServer.js";
+import { createProfile } from "../../../shared/factories.js";
+
+test.describe("Sidebar search", () => {
+ let mockServer;
+
+ test.beforeEach(async ({ page }) => {
+ mockServer = new MockServer();
+ mockServer.addTypeaheadProfiles([
+ createProfile({
+ did: "did:plc:profile1",
+ handle: "alice.bsky.social",
+ displayName: "Alice",
+ }),
+ ]);
+ mockServer.addSearchProfiles([
+ createProfile({
+ did: "did:plc:profile1",
+ handle: "alice.bsky.social",
+ displayName: "Alice",
+ }),
+ ]);
+ await mockServer.setup(page);
+ await login(page);
+ });
+
+ test("shows typeahead results in the right column", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 });
+
+ const input = page.locator('[data-testid="sidebar-search-input"]');
+ await expect(input).toBeVisible();
+ await input.fill("ali");
+
+ const typeahead = page.locator('[data-testid="sidebar-search-typeahead"]');
+ await expect(typeahead).toBeVisible();
+ await expect(
+ typeahead.locator('[data-testid="sidebar-search-typeahead-result"]'),
+ ).toHaveCount(1);
+ await expect(typeahead).toContainText("@alice.bsky.social");
+ });
+
+ test("navigates to the search page on Enter", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 });
+
+ await page.locator('[data-testid="sidebar-search-input"]').fill("ali");
+ await page.locator('[data-testid="sidebar-search-input"]').press("Enter");
+
+ await expect(page).toHaveURL(/\/search\?q=ali/);
+ await expect(page.locator("#search-view")).toBeVisible({ timeout: 10000 });
+ await expect(page.locator("#search-view .search-input")).toHaveValue("ali");
+ await page.locator('#search-view [data-testid="tab-profiles"]').click();
+ await expect(page.locator("#search-view .profile-list-item")).toHaveCount(
+ 1,
+ { timeout: 10000 },
+ );
+ });
+
+ test("navigates to a profile from a typeahead result", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 });
+
+ await page.locator('[data-testid="sidebar-search-input"]').fill("ali");
+ await page
+ .locator('[data-testid="sidebar-search-typeahead-result"]')
+ .first()
+ .click();
+
+ await expect(page).toHaveURL(/\/profile\/alice\.bsky\.social/);
+ await expect(
+ page.locator('[data-testid="sidebar-search-input"]'),
+ ).toHaveValue("");
+ await expect(
+ page.locator('[data-testid="sidebar-search-typeahead"]'),
+ ).toHaveCount(0);
+ });
+
+ test("is hidden on the search page itself", async ({ page }) => {
+ await page.goto("/search");
+ await expect(page.locator("#search-view")).toBeVisible({ timeout: 10000 });
+
+ await expect(
+ page.locator('[data-testid="sidebar-search-input"]'),
+ ).toHaveCount(0);
+ });
+});
diff --git a/tests/unit/specs/components/sidebar-search.test.js b/tests/unit/specs/components/sidebar-search.test.js
new file mode 100644
index 00000000..3a5a0107
--- /dev/null
+++ b/tests/unit/specs/components/sidebar-search.test.js
@@ -0,0 +1,272 @@
+import { describe, it, beforeEach, afterEach, mock } from "node:test";
+import assert from "node:assert/strict";
+import "/js/components/sidebar-search.js";
+import { makeTestDataLayer } from "../../testHelpers.js";
+import { createProfile } from "../../../shared/factories.js";
+
+describe("sidebar-search", () => {
+ const alice = createProfile({
+ did: "did:plc:alice",
+ handle: "alice.test",
+ displayName: "Alice",
+ });
+
+ let originalRouter;
+ let navigations;
+
+ async function flush() {
+ // Two ticks: effect re-renders are scheduled, and the typeahead load
+ // resolves a microtask before the render it triggers.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+
+ async function mount({ actors = [alice], isAuthenticated = false } = {}) {
+ const searchProfilesTypeahead = mock.fn(async () => ({ actors }));
+ const dataLayer = makeTestDataLayer({ api: { searchProfilesTypeahead } });
+ await dataLayer.preferencesProvider.fetchPreferences();
+ const element = document.createElement("sidebar-search");
+ element.dataLayer = dataLayer;
+ element.isAuthenticated = isAuthenticated;
+ document.body.appendChild(element);
+ return { element, dataLayer, searchProfilesTypeahead };
+ }
+
+ function input(element) {
+ return element.querySelector("[data-testid='sidebar-search-input']");
+ }
+
+ async function type(element, value) {
+ const field = input(element);
+ field.value = value;
+ field.dispatchEvent(new window.InputEvent("input", { bubbles: true }));
+ await flush();
+ }
+
+ function pressKey(element, key) {
+ input(element).dispatchEvent(
+ new window.KeyboardEvent("keydown", { key, bubbles: true }),
+ );
+ }
+
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ navigations = [];
+ originalRouter = window.router;
+ window.router = { go: (href) => navigations.push(href) };
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ window.router = originalRouter;
+ });
+
+ it("renders a search input with no typeahead until the user types", async () => {
+ const { element } = await mount();
+
+ assert(input(element) !== null);
+ assert.deepEqual(
+ element.querySelector("[data-testid='sidebar-search-typeahead']"),
+ null,
+ );
+ });
+
+ it("shows typeahead profile results while typing", async () => {
+ const { element, searchProfilesTypeahead } = await mount();
+
+ await type(element, "ali");
+
+ assert.deepEqual(searchProfilesTypeahead.mock.calls[0].arguments[0], "ali");
+ const typeahead = element.querySelector(
+ "[data-testid='sidebar-search-typeahead']",
+ );
+ assert(typeahead !== null);
+ assert.deepEqual(
+ typeahead
+ .querySelector("[data-testid='sidebar-search-typeahead-search-row']")
+ .textContent.trim(),
+ "ali",
+ );
+ const results = typeahead.querySelectorAll(
+ "[data-testid='sidebar-search-typeahead-result']",
+ );
+ assert.deepEqual(results.length, 1);
+ assert.deepEqual(results[0].getAttribute("href"), "/profile/alice.test");
+ });
+
+ it("keeps its results out of the search view's typeahead store", async () => {
+ const { element, dataLayer } = await mount();
+ dataLayer.dataStore.$searchTypeaheadResults.set({
+ actors: [{ did: "did:plc:searchview" }],
+ });
+
+ await type(element, "ali");
+
+ assert.deepEqual(
+ dataLayer.dataStore.$sidebarSearchTypeaheadResults.get().actors[0].did,
+ "did:plc:alice",
+ );
+ assert.deepEqual(
+ dataLayer.dataStore.$searchTypeaheadResults.get().actors[0].did,
+ "did:plc:searchview",
+ );
+ });
+
+ it("navigates to the search page on Enter", async () => {
+ const { element } = await mount();
+
+ await type(element, " cats and dogs ");
+ pressKey(element, "Enter");
+
+ assert.deepEqual(navigations, ["/search?q=cats+and+dogs"]);
+ });
+
+ it("navigates to the search page when the search row is clicked", async () => {
+ const { element } = await mount();
+
+ await type(element, "cats");
+ element
+ .querySelector("[data-testid='sidebar-search-typeahead-search-row']")
+ .click();
+ await flush();
+
+ assert.deepEqual(navigations, ["/search?q=cats"]);
+ assert.deepEqual(
+ element.querySelector("[data-testid='sidebar-search-typeahead']"),
+ null,
+ );
+ });
+
+ it("clears and unfocuses the input after committing a search", async () => {
+ const { element, dataLayer } = await mount();
+
+ input(element).focus();
+ await type(element, "cats");
+ pressKey(element, "Enter");
+ await flush();
+
+ assert.deepEqual(input(element).value, "");
+ assert(document.activeElement !== input(element));
+ assert.deepEqual(
+ dataLayer.dataStore.$sidebarSearchTypeaheadResults.get(),
+ null,
+ );
+ });
+
+ it("clears and unfocuses the input after selecting a profile", async () => {
+ const { element, dataLayer } = await mount();
+
+ input(element).focus();
+ await type(element, "ali");
+ element
+ .querySelector("[data-testid='sidebar-search-typeahead-result']")
+ .click();
+ await flush();
+
+ assert.deepEqual(input(element).value, "");
+ assert(document.activeElement !== input(element));
+ assert.deepEqual(
+ element.querySelector("[data-testid='sidebar-search-typeahead']"),
+ null,
+ );
+ assert.deepEqual(
+ dataLayer.dataStore.$sidebarSearchTypeaheadResults.get(),
+ null,
+ );
+ });
+
+ it("does not navigate when the query is blank", async () => {
+ const { element } = await mount();
+
+ await type(element, " ");
+ pressKey(element, "Enter");
+
+ assert.deepEqual(navigations, []);
+ assert.deepEqual(
+ element.querySelector("[data-testid='sidebar-search-typeahead']"),
+ null,
+ );
+ });
+
+ it("clears the input and typeahead with the clear button", async () => {
+ const { element, dataLayer } = await mount();
+
+ await type(element, "cats");
+ element
+ .querySelector("[data-testid='sidebar-search-clear-button']")
+ .click();
+ await flush();
+
+ assert.deepEqual(input(element).value, "");
+ assert.deepEqual(
+ element.querySelector("[data-testid='sidebar-search-typeahead']"),
+ null,
+ );
+ assert.deepEqual(
+ dataLayer.dataStore.$sidebarSearchTypeaheadResults.get(),
+ null,
+ );
+ });
+
+ it("clears the input on Escape", async () => {
+ const { element } = await mount();
+
+ await type(element, "cats");
+ pressKey(element, "Escape");
+ await flush();
+
+ assert.deepEqual(input(element).value, "");
+ assert.deepEqual(
+ element.querySelector("[data-testid='sidebar-search-typeahead']"),
+ null,
+ );
+ });
+
+ it("hides the typeahead on blur and restores it on focus", async () => {
+ const { element } = await mount();
+
+ await type(element, "cats");
+ input(element).dispatchEvent(new window.FocusEvent("blur"));
+ await flush();
+ assert.deepEqual(
+ element.querySelector("[data-testid='sidebar-search-typeahead']"),
+ null,
+ );
+
+ input(element).dispatchEvent(new window.FocusEvent("focus"));
+ await flush();
+ assert(
+ element.querySelector("[data-testid='sidebar-search-typeahead']") !==
+ null,
+ );
+ });
+
+ it("records a recent search when authenticated", async () => {
+ const { element, dataLayer } = await mount({ isAuthenticated: true });
+ const addRecentSearch = mock.method(
+ dataLayer.mutations,
+ "addRecentSearch",
+ async () => {},
+ );
+
+ await type(element, "cats");
+ pressKey(element, "Enter");
+
+ assert.deepEqual(addRecentSearch.mock.calls[0].arguments[0], "cats");
+ });
+
+ it("does not record a recent search when logged out", async () => {
+ const { element, dataLayer } = await mount({ isAuthenticated: false });
+ const addRecentSearch = mock.method(
+ dataLayer.mutations,
+ "addRecentSearch",
+ async () => {},
+ );
+
+ await type(element, "cats");
+ pressKey(element, "Enter");
+
+ assert.deepEqual(addRecentSearch.mock.calls.length, 0);
+ assert.deepEqual(navigations, ["/search?q=cats"]);
+ });
+});
diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js
index e93bbc0b..524153e6 100644
--- a/tests/unit/specs/dataLayer/requests.test.js
+++ b/tests/unit/specs/dataLayer/requests.test.js
@@ -1266,6 +1266,82 @@ describe("loadSearchTypeahead", () => {
});
});
+describe("loadSidebarSearchTypeahead", () => {
+ it("should store the search results and hydrate profiles", async () => {
+ const dataStore = new DataStore();
+ const mockApi = {
+ searchProfilesTypeahead: async () => ({
+ actors: [{ did: "did:plc:a" }],
+ }),
+ };
+ const requests = makeRequests(mockApi, dataStore);
+
+ await requests.loadSidebarSearchTypeahead("alice");
+
+ const stored = dataStore.$sidebarSearchTypeaheadResults.get();
+ assert.deepEqual(stored.actors.length, 1);
+ assert.deepEqual(stored.actors[0].did, "did:plc:a");
+ assert.deepEqual(dataStore.$profiles.get("did:plc:a"), {
+ did: "did:plc:a",
+ });
+ });
+
+ it("should clear results when query is empty", async () => {
+ const dataStore = new DataStore();
+ dataStore.$sidebarSearchTypeaheadResults.set({ actors: [{ did: "x" }] });
+ const mockApi = {
+ searchProfilesTypeahead: async () => ({ actors: [] }),
+ };
+ const requests = makeRequests(mockApi, dataStore);
+
+ await requests.loadSidebarSearchTypeahead("");
+
+ assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null);
+ });
+
+ it("should discard in-flight responses after the query is cleared", async () => {
+ const dataStore = new DataStore();
+ let resolveSearch;
+ const searchPromise = new Promise((resolve) => {
+ resolveSearch = resolve;
+ });
+ const mockApi = {
+ searchProfilesTypeahead: async () => {
+ await searchPromise;
+ return { actors: [{ did: "stale" }] };
+ },
+ };
+ const requests = makeRequests(mockApi, dataStore);
+
+ const inFlight = requests.loadSidebarSearchTypeahead("query");
+ await requests.loadSidebarSearchTypeahead("");
+ resolveSearch();
+ await inFlight;
+
+ assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null);
+ });
+
+ it("should not disturb the search view's typeahead results", async () => {
+ const dataStore = new DataStore();
+ const mockApi = {
+ searchProfilesTypeahead: async () => ({
+ actors: [{ did: "did:plc:sidebar" }],
+ }),
+ };
+ const requests = makeRequests(mockApi, dataStore);
+ await requests.loadSearchTypeahead("alice");
+
+ await requests.loadSidebarSearchTypeahead("bob");
+ await requests.loadSidebarSearchTypeahead("");
+
+ assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null);
+ assert.deepEqual(
+ dataStore.$searchTypeaheadResults.get().actors[0].did,
+ "did:plc:sidebar",
+ );
+ });
+});
+
describe("loadFeedSearch", () => {
it("should clear results when query is empty", async () => {
const dataStore = new DataStore();
diff --git a/tests/unit/specs/mainLayout.test.js b/tests/unit/specs/mainLayout.test.js
index 30006373..3027fa76 100644
--- a/tests/unit/specs/mainLayout.test.js
+++ b/tests/unit/specs/mainLayout.test.js
@@ -125,6 +125,27 @@ describe("MainLayout", () => {
);
});
+ it("mounts the sidebar search above trending, except on the search page", async () => {
+ setRoute({ layoutOptions: { activeNavItem: "home" } });
+ await flushRender();
+
+ const rightColumn = harness.appRoot.querySelector(".view-column-right");
+ const elements = [...rightColumn.children].map((child) =>
+ child.tagName.toLowerCase(),
+ );
+ assert.deepEqual(elements, ["sidebar-search", "trending-pane"]);
+
+ setRoute({ layoutOptions: { activeNavItem: "search" } });
+ await flushRender();
+
+ assert.deepEqual(
+ harness.appRoot
+ .querySelector(".view-column-right")
+ .querySelector("sidebar-search"),
+ null,
+ );
+ });
+
it("derives the active nav item from the current route options", async () => {
setRoute({ layoutOptions: { activeNavItem: "home" } });
await flushRender();