diff --git a/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js b/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js
index c721f8a00b4..dab3c29d0ee 100644
--- a/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js
+++ b/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js
@@ -83,6 +83,10 @@ export default class KeyboardShortcutsHelp extends Component {
keys2: [CTRL, ALT, "f"],
keysDelimiter: PLUS,
}),
+ filter_sidebar: buildShortcut("application.filter_sidebar", {
+ keys1: [META, "/"],
+ keysDelimiter: PLUS,
+ }),
help: buildShortcut("application.help", { keys1: ["?"] }),
dismiss_new: buildShortcut("application.dismiss_new", {
keys1: ["x", "r"],
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs b/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs
index f080dc5fc43..8b6cb1fb812 100644
--- a/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs
+++ b/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs
@@ -1,45 +1,46 @@
-
-
- {{#each this.section.links as |link|}}
-
- {{/each}}
-
\ No newline at end of file
+{{#if this.shouldDisplay}}
+
+ {{#each this.filteredLinks key="name" as |link|}}
+
+ {{/each}}
+
+{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/api-section.js b/app/assets/javascripts/discourse/app/components/sidebar/api-section.js
index 044a557e02b..65378fdee24 100644
--- a/app/assets/javascripts/discourse/app/components/sidebar/api-section.js
+++ b/app/assets/javascripts/discourse/app/components/sidebar/api-section.js
@@ -1,10 +1,34 @@
import Component from "@glimmer/component";
import { getOwner, setOwner } from "@ember/application";
+import { inject as service } from "@ember/service";
export default class SidebarApiSection extends Component {
+ @service sidebarState;
+
constructor() {
super(...arguments);
this.section = new this.args.sectionConfig();
setOwner(this.section, getOwner(this));
}
+
+ get shouldDisplay() {
+ if (!this.sidebarState.currentPanel.filterable) {
+ return true;
+ }
+ return (
+ this.sidebarState.filter.length === 0 || this.filteredLinks.length > 0
+ );
+ }
+
+ get filteredLinks() {
+ if (!this.sidebarState.filter) {
+ return this.section.links;
+ }
+ if (this.section.text.toLowerCase().match(this.sidebarState.filter)) {
+ return this.section.links;
+ }
+ return this.section.links.filter((link) => {
+ return link.text.toString().toLowerCase().match(this.sidebarState.filter);
+ });
+ }
}
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs b/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs
index 0661b23b432..14280445d52 100644
--- a/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs
+++ b/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs
@@ -1,6 +1,8 @@
+
{{#each this.sections as |sectionConfig|}}
-{{/each}}
\ No newline at end of file
+{{/each}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs b/app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs
new file mode 100644
index 00000000000..5d7c42e220d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs
@@ -0,0 +1,29 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import i18n from "discourse-common/helpers/i18n";
+
+export default class FilterNoResulsts extends Component {
+ @service sidebarState;
+
+ /**
+ * Component is rendered when panel is filtreable
+ * Visibility is additionally controlled by CSS rule `.sidebar-section-wrapper + .sidebar-no-results`
+ */
+ get shouldDisplay() {
+ return this.sidebarState.currentPanel.filterable;
+ }
+
+
+ {{#if this.shouldDisplay}}
+
+ {{/if}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/filter.gjs b/app/assets/javascripts/discourse/app/components/sidebar/filter.gjs
new file mode 100644
index 00000000000..d72afed6081
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/sidebar/filter.gjs
@@ -0,0 +1,56 @@
+import Component from "@glimmer/component";
+import { Input } from "@ember/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
+import { inject as service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import dIcon from "discourse-common/helpers/d-icon";
+import i18n from "discourse-common/helpers/i18n";
+import { bind } from "discourse-common/utils/decorators";
+
+export default class Filter extends Component {
+ @service sidebarState;
+
+ get shouldDisplay() {
+ return this.sidebarState.currentPanel.filterable;
+ }
+
+ get displayClearFilter() {
+ return this.sidebarState.filter.length > 0;
+ }
+
+ @bind
+ teardown() {
+ this.sidebarState.clearFilter();
+ }
+
+ @action
+ setFilter(event) {
+ this.sidebarState.filter = event.target.value.toLowerCase();
+ }
+
+ @action
+ clearFilter() {
+ this.sidebarState.clearFilter();
+ document.querySelector(".sidebar-filter__input").focus();
+ }
+
+
+ {{#if this.shouldDisplay}}
+
+ {{/if}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js
index cf421dce690..42332ed5828 100644
--- a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js
+++ b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js
@@ -41,6 +41,8 @@ const DEFAULT_BINDINGS = {
"!": { postAction: "showFlags" },
"#": { handler: "goToPost", anonymous: true },
"/": { handler: "toggleSearch", anonymous: true },
+ "meta+/": { handler: "filterSidebar", anonymous: true },
+ [`${PLATFORM_KEY_MODIFIER}+/`]: { handler: "filterSidebar", anonymous: true },
"ctrl+alt+f": { handler: "toggleSearch", anonymous: true, global: true },
"=": { handler: "toggleHamburgerMenu", anonymous: true },
"?": { handler: "showHelpModal", anonymous: true },
@@ -469,6 +471,15 @@ export default {
composer.focusComposer(event);
},
+ filterSidebar() {
+ const filterInput = document.querySelector(".sidebar-filter__input");
+
+ if (filterInput) {
+ this._scrollTo(0);
+ filterInput.focus();
+ }
+ },
+
fullscreenComposer() {
const composer = getOwner(this).lookup("service:composer");
if (composer.get("model")) {
diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js b/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js
index 192b6894d6c..40646bb9d69 100644
--- a/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js
+++ b/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js
@@ -255,4 +255,8 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel {
return defineAdminSection(adminNavSectionData);
});
}
+
+ get filterable() {
+ return true;
+ }
}
diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js b/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js
index b98850b5141..d4fc099286c 100644
--- a/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js
+++ b/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js
@@ -43,6 +43,13 @@ export default class BaseCustomSidebarPanel {
this.hidden || this.#notImplemented();
}
+ /**
+ * @returns {boolean} Controls whether the filter is shown
+ */
+ get filterable() {
+ return false;
+ }
+
#notImplemented() {
throw "not implemented";
}
diff --git a/app/assets/javascripts/discourse/app/services/sidebar-state.js b/app/assets/javascripts/discourse/app/services/sidebar-state.js
index 9f25c744a2c..2e24002df1f 100644
--- a/app/assets/javascripts/discourse/app/services/sidebar-state.js
+++ b/app/assets/javascripts/discourse/app/services/sidebar-state.js
@@ -17,10 +17,10 @@ export default class SidebarState extends Service {
@tracked panels = panels;
@tracked mode = COMBINED_MODE;
@tracked displaySwitchPanelButtons = false;
+ @tracked filter = "";
constructor() {
super(...arguments);
-
this.#reset();
}
@@ -64,4 +64,8 @@ export default class SidebarState extends Service {
this.panels = panels;
this.mode = COMBINED_MODE;
}
+
+ clearFilter() {
+ this.filter = "";
+ }
}
diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss
index 2ba4d097e44..f6c8b462712 100644
--- a/app/assets/stylesheets/common/base/menu-panel.scss
+++ b/app/assets/stylesheets/common/base/menu-panel.scss
@@ -344,6 +344,10 @@
div.discourse-tags {
font-size: var(--font-down-1);
}
+
+ .sidebar-filter {
+ width: calc(100% - 2.35rem);
+ }
}
// Panel / user-notification-list styles. **not** menu panel sizing styles
diff --git a/app/assets/stylesheets/common/base/sidebar.scss b/app/assets/stylesheets/common/base/sidebar.scss
index e34175c8ce4..29dad70e743 100644
--- a/app/assets/stylesheets/common/base/sidebar.scss
+++ b/app/assets/stylesheets/common/base/sidebar.scss
@@ -306,3 +306,54 @@
margin-bottom: 1em;
}
}
+
+.sidebar-filter {
+ margin: 0 var(--d-sidebar-row-horizontal-padding) 0.5em
+ var(--d-sidebar-row-horizontal-padding);
+ display: flex;
+ border: 1px solid var(--primary-400);
+ border-radius: var(--d-input-border-radius);
+ align-items: center;
+ justify-content: space-between;
+ background: var(--secondary);
+ width: calc(
+ var(--d-sidebar-width) - var(--d-sidebar-row-horizontal-padding) * 2
+ );
+
+ &:focus-within {
+ border-color: var(--tertiary);
+ outline: 2px solid var(--tertiary);
+ outline-offset: -2px;
+ }
+
+ &__input[type="text"] {
+ border: 0;
+ margin-bottom: 0;
+ width: 50px;
+ height: 2em;
+ &:focus-within {
+ outline: 0;
+ }
+ width: calc(100% - 2em);
+ }
+
+ &__clear {
+ width: 2em;
+ height: 2em;
+ color: var(--primary-medium);
+ background-color: var(--secondary);
+ }
+}
+.sidebar-no-results {
+ margin: 0.5em var(--d-sidebar-row-horizontal-padding) 0
+ var(--d-sidebar-row-horizontal-padding);
+ &__title {
+ font-weight: bold;
+ }
+}
+.sidebar-no-results {
+ display: block;
+}
+.sidebar-section-wrapper + .sidebar-no-results {
+ display: none;
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 0da2b8c827b..8a13f510652 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4243,6 +4243,7 @@ en:
user_profile_menu: "%{shortcut} Open user menu"
show_incoming_updated_topics: "%{shortcut} Show updated topics"
search: "%{shortcut} Search"
+ filter_sidebar: "%{shortcut} Filter sidebar"
help: "%{shortcut} Open keyboard help"
dismiss_new: "%{shortcut} Dismiss New"
dismiss_topics: "%{shortcut} Dismiss Topics"
@@ -4677,6 +4678,11 @@ en:
panels:
forum:
label: Forum
+ filter: "Filter"
+ clear_filter: "Clear filter"
+ no_results:
+ title: "No results"
+ description: "We couldn’t find anything matching ‘%{filter}’"
welcome_topic_banner:
title: "Create your Welcome Topic"
diff --git a/spec/system/admin_sidebar_navigation_spec.rb b/spec/system/admin_sidebar_navigation_spec.rb
index 1f4a4ed1964..160517f9832 100644
--- a/spec/system/admin_sidebar_navigation_spec.rb
+++ b/spec/system/admin_sidebar_navigation_spec.rb
@@ -5,6 +5,7 @@ describe "Admin Revamp | Sidebar Navigation", type: :system do
let(:sidebar) { PageObjects::Components::NavigationMenu::Sidebar.new }
let(:sidebar_dropdown) { PageObjects::Components::SidebarHeaderDropdown.new }
+ let(:filter) { PageObjects::Components::Filter.new }
before do
SiteSetting.admin_sidebar_enabled_groups = Group::AUTO_GROUPS[:admins]
@@ -60,4 +61,34 @@ describe "Admin Revamp | Sidebar Navigation", type: :system do
expect(sidebar).to have_no_section("admin-nav-section-root")
end
end
+
+ it "allows links to be filtered" do
+ visit("/admin")
+ all_links_count = page.all(".sidebar-section-link-content-text").count
+
+ links = page.all(".sidebar-section-link-content-text")
+ expect(links.count).to eq(all_links_count)
+ expect(page).to have_no_css(".sidebar-no-results")
+
+ filter.filter("ie")
+ links = page.all(".sidebar-section-link-content-text")
+ expect(links.count).to eq(2)
+ expect(links.map(&:text)).to eq(["Preview Summary", "User Fields"])
+ expect(page).to have_no_css(".sidebar-no-results")
+
+ filter.filter("ieeee")
+ expect(page).to have_no_css(".sidebar-section-link-content-text")
+ expect(page).to have_css(".sidebar-no-results")
+
+ filter.clear
+ links = page.all(".sidebar-section-link-content-text")
+ expect(links.count).to eq(all_links_count)
+ expect(page).to have_no_css(".sidebar-no-results")
+
+ # When match section title, display all links
+ filter.filter("Backups")
+ links = page.all(".sidebar-section-link-content-text")
+ expect(links.count).to eq(2)
+ expect(links.map(&:text)).to eq(%w[Backups Logs])
+ end
end
diff --git a/spec/system/page_objects/components/filter.rb b/spec/system/page_objects/components/filter.rb
new file mode 100644
index 00000000000..3bcdd671e4a
--- /dev/null
+++ b/spec/system/page_objects/components/filter.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class Filter < PageObjects::Components::Base
+ def filter(text)
+ page.find(".sidebar-filter__input").fill_in(with: text)
+ self
+ end
+
+ def clear
+ page.find(".sidebar-filter__clear").click
+ self
+ end
+ end
+ end
+end