DEV: Restructure search menu so that it can be rendered outside of header (#23852)

This commit is contained in:
Mark VanLandingham 2023-10-10 11:36:32 -05:00 committed by GitHub
parent ef5cb6e7ed
commit e110256cb0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 322 additions and 170 deletions

View File

@ -0,0 +1,7 @@
<MenuPanel @animationClass={{this.animationClass}}>
<SearchMenu
@closeSearchMenu={{@closeSearchMenu}}
@inlineResults={{true}}
@autofocusInput={{true}}
/>
</MenuPanel>

View File

@ -0,0 +1,11 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class SearchMenuPanel extends Component {
@service site;
get animationClass() {
return this.site.mobileView || this.site.narrowDesktopView
? "slide-in"
: "drop-down";
}
}

View File

@ -1,23 +1,83 @@
<MenuPanel @animationClass={{this.animationClass}}>
<SearchMenu::MenuPanelContents
@clearTopicContext={{this.clearTopicContext}}
@clearPMInboxContext={{this.clearPMInboxContext}}
@inPMInboxContext={{this.inPMInboxContext}}
@searchTermChanged={{this.searchTermChanged}}
@loading={{this.loading}}
@fullSearchUrl={{this.fullSearchUrl}}
@fullSearch={{this.fullSearch}}
@triggerSearch={{this.triggerSearch}}
@clearSearch={{this.clearSearch}}
@includesTopics={{this.includesTopics}}
@noResults={{this.noResults}}
@results={{this.results}}
@invalidTerm={{this.invalidTerm}}
@suggestionKeyword={{this.suggestionKeyword}}
@suggestionResults={{this.suggestionResults}}
@searchTopics={{this.includesTopics}}
@typeFilter={{this.typeFilter}}
@updateTypeFilter={{this.updateTypeFilter}}
@closeSearchMenu={{@closeSearchMenu}}
/>
</MenuPanel>
<div class={{this.classNames}} {{did-insert this.setupEventListeners}}>
<div class="search-input">
{{#if this.search.inTopicContext}}
<DButton
@icon="times"
@label="search.in_this_topic"
@title="search.in_this_topic_tooltip"
@action={{this.clearTopicContext}}
class="btn-small search-context"
/>
{{else if this.inPMInboxContext}}
<DButton
@icon="times"
@label="search.in_messages"
@title="search.in_messages_tooltip"
@action={{this.clearPMInboxContext}}
class="btn-small search-context"
/>
{{/if}}
<SearchMenu::SearchTerm
@searchTermChanged={{this.searchTermChanged}}
@typeFilter={{this.typeFilter}}
@updateTypeFilter={{this.updateTypeFilter}}
@triggerSearch={{this.triggerSearch}}
@fullSearch={{this.fullSearch}}
@clearPMInboxContext={{this.clearPMInboxContext}}
@clearTopicContext={{this.clearTopicContext}}
@closeSearchMenu={{this.close}}
@openSearchMenu={{this.open}}
@autofocus={{@autofocusInput}}
/>
{{#if this.loading}}
<div class="searching">
{{loading-spinner}}
</div>
{{else}}
<div class="searching">
{{#if this.search.activeGlobalSearchTerm}}
<SearchMenu::ClearButton @clearSearch={{this.clearSearch}} />
{{/if}}
<SearchMenu::AdvancedButton @href={{this.advancedSearchButtonHref}} />
</div>
{{/if}}
</div>
{{#if @inlineResults}}
<SearchMenu::Results
@loading={{this.loading}}
@noResults={{this.noResults}}
@results={{this.results}}
@invalidTerm={{this.invalidTerm}}
@suggestionKeyword={{this.suggestionKeyword}}
@suggestionResults={{this.suggestionResults}}
@searchTopics={{this.includesTopics}}
@inPMInboxContext={{this.inPMInboxContext}}
@triggerSearch={{this.triggerSearch}}
@updateTypeFilter={{this.updateTypeFilter}}
@closeSearchMenu={{this.close}}
@searchTermChanged={{this.searchTermChanged}}
@clearSearch={{this.clearSearch}}
/>
{{else if this.displayMenuPanelResults}}
<MenuPanel>
<SearchMenu::Results
@loading={{this.loading}}
@noResults={{this.noResults}}
@results={{this.results}}
@invalidTerm={{this.invalidTerm}}
@suggestionKeyword={{this.suggestionKeyword}}
@suggestionResults={{this.suggestionResults}}
@searchTopics={{this.includesTopics}}
@inPMInboxContext={{this.inPMInboxContext}}
@triggerSearch={{this.triggerSearch}}
@updateTypeFilter={{this.updateTypeFilter}}
@closeSearchMenu={{this.close}}
@searchTermChanged={{this.searchTermChanged}}
@clearSearch={{this.clearSearch}}
/>
</MenuPanel>
{{/if}}
</div>

View File

@ -22,7 +22,6 @@ const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi;
const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi;
const SUGGESTIONS_REGEXP = /(in:|status:|order:|:)([a-zA-Z]*)$/gi;
export const SEARCH_INPUT_ID = "search-term";
export const SEARCH_BUTTON_ID = "search-button";
export const MODIFIER_REGEXP = /.*(\#|\@|:).*$/gi;
export const DEFAULT_TYPE_FILTER = "exclude_topics";
@ -30,16 +29,11 @@ export function focusSearchInput() {
document.getElementById(SEARCH_INPUT_ID).focus();
}
export function focusSearchButton() {
document.getElementById(SEARCH_BUTTON_ID).focus();
}
export default class SearchMenu extends Component {
@service search;
@service currentUser;
@service siteSettings;
@service appEvents;
@service site;
@tracked loading = false;
@tracked results = {};
@ -50,13 +44,42 @@ export default class SearchMenu extends Component {
@tracked suggestionKeyword = false;
@tracked suggestionResults = [];
@tracked invalidTerm = false;
@tracked menuPanelOpen = false;
_debouncer = null;
_activeSearch = null;
get animationClass() {
return this.site.mobileView || this.site.narrowDesktopView
? "slide-in"
: "drop-down";
@bind
setupEventListeners() {
document.addEventListener("mousedown", this.onDocumentPress, true);
document.addEventListener("touchend", this.onDocumentPress, {
capture: true,
passive: true,
});
}
willDestroy() {
document.removeEventListener("mousedown", this.onDocumentPress);
document.removeEventListener("touchend", this.onDocumentPress);
super.willDestroy(...arguments);
}
@bind
onDocumentPress(event) {
if (!event.target.closest(".search-menu-container.menu-panel-results")) {
this.menuPanelOpen = false;
}
}
get classNames() {
const classes = ["search-menu-container"];
if (!this.args.inlineResults) {
classes.push("menu-panel-results");
}
return classes.join(" ");
}
get includesTopics() {
@ -71,6 +94,23 @@ export default class SearchMenu extends Component {
return false;
}
@action
close() {
if (this.args?.closeSearchMenu) {
return this.args.closeSearchMenu();
}
// We want to blur the active element (search input) when in stand-alone mode
// so that when we focus on the search input again, the menu panel pops up
document.activeElement.blur();
this.menuPanelOpen = false;
}
@action
open() {
this.menuPanelOpen = true;
}
@bind
fullSearchUrl(opts) {
let url = "/search";
@ -95,6 +135,18 @@ export default class SearchMenu extends Component {
return getURL(url);
}
get advancedSearchButtonHref() {
return this.fullSearchUrl({ expanded: true });
}
get displayMenuPanelResults() {
if (this.args.inlineResults) {
return false;
}
return this.menuPanelOpen;
}
@bind
clearSearch(e) {
e.stopPropagation();

View File

@ -1,63 +0,0 @@
<div class="search-input">
{{#if this.search.inTopicContext}}
<DButton
@icon="times"
@label="search.in_this_topic"
@title="search.in_this_topic_tooltip"
@action={{@clearTopicContext}}
class="btn-small search-context"
/>
{{else if @inPMInboxContext}}
<DButton
@icon="times"
@label="search.in_messages"
@title="search.in_messages_tooltip"
@action={{@clearPMInboxContext}}
class="btn-small search-context"
/>
{{/if}}
<SearchMenu::SearchTerm
@searchTermChanged={{@searchTermChanged}}
@typeFilter={{@typeFilter}}
@updateTypeFilter={{@updateTypeFilter}}
@triggerSearch={{@triggerSearch}}
@fullSearch={{@fullSearch}}
@clearPMInboxContext={{@clearPMInboxContext}}
@clearTopicContext={{@clearTopicContext}}
@closeSearchMenu={{@closeSearchMenu}}
/>
{{#if @loading}}
<div class="searching">
{{loading-spinner}}
</div>
{{else}}
<div class="searching">
{{#if this.search.activeGlobalSearchTerm}}
<SearchMenu::ClearButton @clearSearch={{@clearSearch}} />
{{/if}}
<SearchMenu::AdvancedButton @href={{this.advancedSearchButtonHref}} />
</div>
{{/if}}
</div>
{{#if (and this.search.inTopicContext (not @includesTopics))}}
<SearchMenu::BrowserSearchTip />
{{else}}
{{#unless @loading}}
<SearchMenu::Results
@noResults={{@noResults}}
@results={{@results}}
@invalidTerm={{@invalidTerm}}
@suggestionKeyword={{@suggestionKeyword}}
@suggestionResults={{@suggestionResults}}
@searchTopics={{@includesTopics}}
@inPMInboxContext={{@inPMInboxContext}}
@triggerSearch={{@triggerSearch}}
@updateTypeFilter={{@updateTypeFilter}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/unless}}
{{/if}}

View File

@ -1,10 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class MenuPanelContents extends Component {
@service search;
get advancedSearchButtonHref() {
return this.args.fullSearchUrl({ expanded: true });
}
}

View File

@ -1,52 +1,56 @@
<div class="results">
{{#if @suggestionKeyword}}
<SearchMenu::Results::Assistant
@suggestionKeyword={{@suggestionKeyword}}
@results={{@suggestionResults}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{else if this.termTooShort}}
<div class="no-results">{{i18n "search.too_short"}}</div>
{{else if this.noTopicResults}}
<div class="no-results">{{i18n "search.no_results"}}</div>
{{else if this.renderInitialOptions}}
<SearchMenu::Results::InitialOptions
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{else}}
{{#if @searchTopics}}
{{! render results after a search has been performed }}
{{#if this.resultTypesWithComponent}}
<SearchMenu::Results::Types
@resultTypes={{this.resultTypesWithComponent}}
@topicResultsOnly={{true}}
@closeSearchMenu={{@closeSearchMenu}}
/>
<SearchMenu::Results::MoreLink
@updateTypeFilter={{@updateTypeFilter}}
@triggerSearch={{@triggerSearch}}
@resultTypes={{this.resultTypesWithComponent}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/if}}
{{#if (and this.search.inTopicContext (not @searchTopics))}}
<SearchMenu::BrowserSearchTip />
{{else if (not @loading)}}
<div class="results">
{{#if @suggestionKeyword}}
<SearchMenu::Results::Assistant
@suggestionKeyword={{@suggestionKeyword}}
@results={{@suggestionResults}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{else if this.termTooShort}}
<div class="no-results">{{i18n "search.too_short"}}</div>
{{else if this.noTopicResults}}
<div class="no-results">{{i18n "search.no_results"}}</div>
{{else if this.renderInitialOptions}}
<SearchMenu::Results::InitialOptions
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{else}}
{{#unless @inPMInboxContext}}
{{! render the first couple suggestions before a search has been performed}}
<SearchMenu::Results::InitialOptions
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{#if @searchTopics}}
{{! render results after a search has been performed }}
{{#if this.resultTypesWithComponent}}
<SearchMenu::Results::Types
@resultTypes={{this.resultTypesWithComponent}}
@topicResultsOnly={{true}}
@closeSearchMenu={{@closeSearchMenu}}
/>
<SearchMenu::Results::MoreLink
@updateTypeFilter={{@updateTypeFilter}}
@triggerSearch={{@triggerSearch}}
@resultTypes={{this.resultTypesWithComponent}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/if}}
{{/unless}}
{{else}}
{{#unless @inPMInboxContext}}
{{! render the first couple suggestions before a search has been performed}}
<SearchMenu::Results::InitialOptions
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{#if this.resultTypesWithComponent}}
<SearchMenu::Results::Types
@resultTypes={{this.resultTypesWithComponent}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/if}}
{{/unless}}
{{/if}}
{{/if}}
{{/if}}
</div>
</div>
{{/if}}

View File

@ -3,10 +3,7 @@ import getURL from "discourse-common/lib/get-url";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { debounce } from "discourse-common/utils/decorators";
import {
focusSearchButton,
focusSearchInput,
} from "discourse/components/search-menu";
import { focusSearchInput } from "discourse/components/search-menu";
export default class AssistantItem extends Component {
@service search;
@ -65,7 +62,6 @@ export default class AssistantItem extends Component {
}
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;

View File

@ -2,7 +2,11 @@
{{! template-lint-disable no-invalid-interactive }}
<div class="search-menu__show-more" {{on "keyup" this.onKeyup}}>
{{#if this.moreUrl}}
<a href={{this.moreUrl}} class="filter search-link">
<a
href={{this.moreUrl}}
{{on "click" this.transitionToMoreUrl}}
class="filter search-link"
>
{{i18n "more"}}...
</a>
{{else if this.topicResults.more}}

View File

@ -1,7 +1,7 @@
import Component from "@glimmer/component";
import DiscourseURL from "discourse/lib/url";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { focusSearchButton } from "discourse/components/search-menu";
export default class MoreLink extends Component {
@service search;
@ -17,16 +17,24 @@ export default class MoreLink extends Component {
return this.topicResults.moreUrl && this.topicResults.moreUrl();
}
@action
transitionToMoreUrl(event) {
event.preventDefault();
this.args.closeSearchMenu();
DiscourseURL.routeTo(this.moreUrl);
return false;
}
@action
moreOfType(type) {
this.args.updateTypeFilter(type);
this.args.triggerSearch();
this.args.closeSearchMenu();
}
@action
onKeyup(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;

View File

@ -2,7 +2,6 @@ import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import User from "discourse/models/user";
import { action } from "@ember/object";
import { focusSearchButton } from "discourse/components/search-menu";
export default class RecentSearches extends Component {
@service currentUser;
@ -32,7 +31,6 @@ export default class RecentSearches extends Component {
@action
onKeyup(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;

View File

@ -8,7 +8,11 @@
{{! template-lint-disable no-pointer-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}
<li class="item" {{on "keydown" this.onKeydown}}>
<a href={{or result.url result.path}} class="search-link">
<a
href={{or result.url result.path}}
{{on "click" this.onClick}}
class="search-link"
>
<resultType.component @result={{result}} />
</a>
</li>

View File

@ -1,7 +1,6 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { focusSearchButton } from "discourse/components/search-menu";
export default class Types extends Component {
@service search;
@ -20,10 +19,14 @@ export default class Types extends Component {
);
}
@action
onClick() {
this.args.closeSearchMenu();
}
@action
onKeydown(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;

View File

@ -7,5 +7,6 @@
aria-label={{i18n "search.title"}}
{{on "keyup" this.onKeyup}}
{{on "input" this.updateSearchTerm}}
{{on "focus" @openSearchMenu}}
{{did-insert this.focus}}
/>

View File

@ -6,7 +6,6 @@ import { inject as service } from "@ember/service";
import {
DEFAULT_TYPE_FILTER,
SEARCH_INPUT_ID,
focusSearchButton,
} from "discourse/components/search-menu";
const SECOND_ENTER_MAX_DELAY = 15000;
@ -35,19 +34,22 @@ export default class SearchTerm extends Component {
@action
focus(element) {
element.focus();
element.select();
if (this.args.autofocus) {
element.focus();
element.select();
}
}
@action
onKeyup(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;
}
this.args.openSearchMenu();
this.search.handleArrowUpOrDown(e);
if (e.key === "Enter") {

View File

@ -12,7 +12,8 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
import { logSearchLinkClick } from "discourse/lib/search";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
import { SEARCH_BUTTON_ID } from "discourse/components/search-menu";
const SEARCH_BUTTON_ID = "search-button";
let _extraHeaderIcons = [];
@ -393,14 +394,17 @@ createWidget("glimmer-search-menu-wrapper", {
new RenderGlimmer(
this,
"div.widget-component-connector",
hbs`<SearchMenu @closeSearchMenu={{@data.closeSearchMenu}} />`,
{ closeSearchMenu: this.closeSearchMenu.bind(this) }
hbs`<SearchMenuPanel @closeSearchMenu={{@data.closeSearchMenu}} />`,
{
closeSearchMenu: this.closeSearchMenu.bind(this),
}
),
];
},
closeSearchMenu() {
this.sendWidgetAction("toggleSearchMenu");
document.getElementById(SEARCH_BUTTON_ID)?.focus();
},
clickOutside() {

View File

@ -0,0 +1,58 @@
import I18n from "I18n";
import SearchMenu from "discourse/components/search-menu";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { click, fillIn, render, triggerKeyEvent } from "@ember/test-helpers";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
// Note this isn't a full-fledge test of the search menu. Those tests are in
// acceptance/glimmer-search-test.js. This is simply about the rendering of the
// menu panel separate from the search input.
module("Integration | Component | search-menu", function (hooks) {
setupRenderingTest(hooks);
test("rendering standalone", async function (assert) {
await render(<template><SearchMenu /></template>);
assert.ok(
exists(".show-advanced-search"),
"it shows full page search button"
);
assert.notOk(exists(".menu-panel"), "Menu panel is not rendered yet");
await click("#search-term");
assert.ok(
exists(".menu-panel .search-menu-initial-options"),
"Menu panel is rendered with initial options"
);
await fillIn("#search-term", "test");
assert.strictEqual(
query(".label-suffix").textContent.trim(),
I18n.t("search.in_topics_posts"),
"search label reflects context of search"
);
await triggerKeyEvent("#search-term", "keyup", "Enter");
assert.ok(
exists(".search-result-topic"),
"search result is a list of topics"
);
await triggerKeyEvent("#search-term", "keyup", "Escape");
assert.notOk(exists(".menu-panel"), "Menu panel is gone");
await click("#search-term");
await click("#search-term");
assert.ok(
exists(".search-result-topic"),
"Clicking the term brought back search results"
);
});
});

View File

@ -14,7 +14,8 @@
$search-pad-vertical: 0.25em;
$search-pad-horizontal: 0.5em;
.search-menu {
.search-menu,
.search-menu-container {
.menu-panel .panel-body-contents {
overflow-y: auto;
}
@ -60,6 +61,18 @@ $search-pad-horizontal: 0.5em;
margin-right: 0px;
}
&.menu-panel-results {
position: relative;
.menu-panel {
position: absolute;
left: 0;
right: 0;
top: unset;
width: unset;
}
}
.results {
display: flex;
flex-direction: column;