DEV: Auto expand active sections and scroll active link into view (#28237)

This commit is contained in:
Sérgio Saquetim 2024-08-07 14:47:34 -03:00 committed by GitHub
parent 064332ef6e
commit a9abaf408d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 343 additions and 7 deletions

View File

@ -11,7 +11,11 @@ export default class SidebarApiPanels extends Component {
<template>
<div class="sidebar-sections {{this.panelCssClass}}">
<ApiSections @collapsable={{@collapsableSections}} />
<ApiSections
@collapsable={{@collapsableSections}}
@expandActiveSection={{this.sidebarState.currentPanel.expandActiveSection}}
@scrollActiveLinkIntoView={{this.sidebarState.currentPanel.scrollActiveLinkIntoView}}
/>
</div>
</template>
}

View File

@ -1,3 +1,4 @@
import { and, eq } from "truth-helpers";
import Section from "./section";
import SectionLink from "./section-link";
@ -14,6 +15,9 @@ const SidebarApiSection = <template>
@displaySection={{@section.displaySection}}
@hideSectionHeader={{@section.hideSectionHeader}}
@collapsedByDefault={{@section.collapsedByDefault}}
@activeLink={{@section.activeLink}}
@expandWhenActive={{@expandWhenActive}}
@scrollActiveLinkIntoView={{@scrollActiveLinkIntoView}}
>
{{#each @section.filteredLinks key="name" as |link|}}
<SectionLink
@ -23,6 +27,7 @@ const SidebarApiSection = <template>
@model={{link.model}}
@query={{link.query}}
@models={{link.models}}
@currentWhen={{link.currentWhen}}
@href={{link.href}}
@title={{link.title}}
@contentCSSClass={{link.contentCSSClass}}
@ -38,7 +43,6 @@ const SidebarApiSection = <template>
@hoverValue={{link.hoverValue}}
@hoverAction={{link.hoverAction}}
@hoverTitle={{link.hoverTitle}}
@currentWhen={{link.currentWhen}}
@didInsert={{link.didInsert}}
@willDestroy={{link.willDestroy}}
@content={{link.text}}
@ -46,6 +50,10 @@ const SidebarApiSection = <template>
link.contentComponent
status=link.contentComponentArgs
}}
@scrollIntoView={{and
@scrollActiveLinkIntoView
(eq link.name @section.activeLink.name)
}}
/>
{{/each}}
</Section>

View File

@ -6,6 +6,7 @@ import ApiSection from "./api-section";
import PanelHeader from "./panel-header";
export default class SidebarApiSections extends Component {
@service router;
@service sidebarState;
get sections() {
@ -20,7 +21,7 @@ export default class SidebarApiSections extends Component {
}
return sectionConfigs.map((Section) => {
const SidebarSection = prepareSidebarSectionClass(Section);
const SidebarSection = prepareSidebarSectionClass(Section, this.router);
const sectionInstance = new SidebarSection({
filterable:
@ -43,14 +44,19 @@ export default class SidebarApiSections extends Component {
<PanelHeader @sections={{this.filteredSections}} />
{{#each this.filteredSections as |section|}}
<ApiSection @section={{section}} @collapsable={{@collapsable}} />
<ApiSection
@section={{section}}
@collapsable={{@collapsable}}
@expandWhenActive={{@expandActiveSection}}
@scrollActiveLinkIntoView={{@scrollActiveLinkIntoView}}
/>
{{/each}}
</template>
}
// extends the class provided for the section to add functionality we don't want to be overridable when defining custom
// sections using the plugin API, like for example the filtering capabilities
function prepareSidebarSectionClass(Section) {
function prepareSidebarSectionClass(Section, routerService) {
return class extends Section {
constructor({ filterable, sidebarState }) {
super();
@ -82,6 +88,46 @@ function prepareSidebarSectionClass(Section) {
});
}
get activeLink() {
return this.filteredLinks.find((link) => {
try {
const currentWhen = link.currentWhen;
if (typeof currentWhen === "boolean") {
return currentWhen;
}
// TODO detect active links using the href field
const queryParams = link.query || {};
let models;
if (link.model) {
models = [link.model];
} else if (link.models) {
models = link.models;
} else {
models = [];
}
if (typeof currentWhen === "string") {
return currentWhen.split(" ").some((route) =>
routerService.isActive(route, ...models, {
queryParams,
})
);
}
return routerService.isActive(link.route, ...models, {
queryParams,
});
} catch (e) {
// false if ember throws an exception while checking the routes
return false;
}
});
}
get filtered() {
return !this.filterable || this.filteredLinks?.length > 0;
}

View File

@ -1,12 +1,16 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { LinkTo } from "@ember/routing";
import { schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { eq, or } from "truth-helpers";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import deprecated from "discourse-common/lib/deprecated";
import { bind } from "discourse-common/utils/decorators";
import SectionLinkPrefix from "./section-link-prefix";
/**
@ -61,6 +65,14 @@ export default class SectionLink extends Component {
classNames.push(this.args.class);
}
if (
this.args.href &&
typeof this.args.currentWhen === "boolean" &&
this.args.currentWhen
) {
classNames.push("active");
}
return classNames.join(" ");
}
@ -101,9 +113,22 @@ export default class SectionLink extends Component {
}
}
@bind
maybeScrollIntoView(element) {
if (this.args.scrollIntoView) {
schedule("afterRender", () => {
element.scrollIntoView({
block: "center",
});
});
}
}
<template>
{{#if this.shouldDisplay}}
<li
{{didInsert this.maybeScrollIntoView}}
{{didUpdate this.maybeScrollIntoView @scrollIntoView}}
data-list-item-name={{@linkName}}
class="sidebar-section-link-wrapper"
...attributes

View File

@ -19,6 +19,7 @@ import SectionHeader from "./section-header";
export default class SidebarSection extends Component {
@service keyValueStore;
@service router;
@service sidebarState;
sidebarSectionContentId = getSidebarSectionContentId(this.args.sectionName);
@ -26,8 +27,17 @@ export default class SidebarSection extends Component {
this.args.sectionName
);
constructor() {
super(...arguments);
this.router.on("routeDidChange", this, this.expandIfActive);
}
willDestroy() {
super.willDestroy(...arguments);
this.router.off("routeDidChange", this, this.expandIfActive);
this.args.willDestroy?.();
}
@ -47,17 +57,46 @@ export default class SidebarSection extends Component {
);
}
get isActive() {
return !!this.args.activeLink;
}
get activeExpanded() {
return this.sidebarState.activeExpandedSections.has(this.args.sectionName);
}
set activeExpanded(value) {
if (value) {
this.sidebarState.activeExpandedSections.add(this.args.sectionName);
} else {
this.sidebarState.activeExpandedSections.delete(this.args.sectionName);
}
}
@bind
setExpandedState() {
if (!isEmpty(this.sidebarState.filter)) {
return;
}
// initialize the collapsed/expanded state of the section
if (this.isCollapsed) {
this.sidebarState.collapseSection(this.args.sectionName);
} else {
this.sidebarState.expandSection(this.args.sectionName);
}
// override the expanded state if the section is active
this.expandIfActive();
}
@bind
expandIfActive(transition) {
if (transition?.isAborted) {
return;
}
this.activeExpanded = this.args.expandWhenActive && this.isActive;
}
get displaySectionContent() {
@ -65,6 +104,10 @@ export default class SidebarSection extends Component {
return true;
}
if (this.activeExpanded) {
return true;
}
return !this.sidebarState.collapsedSections.has(
this.collapsedSidebarSectionKey
);
@ -72,6 +115,8 @@ export default class SidebarSection extends Component {
@action
toggleSectionDisplay(_, event) {
this.activeExpanded = false;
if (this.displaySectionContent) {
this.sidebarState.collapseSection(this.args.sectionName);
} else {
@ -134,6 +179,7 @@ export default class SidebarSection extends Component {
@sidebarSectionContentId={{this.sidebarSectionContentId}}
@toggleSectionDisplay={{this.toggleSectionDisplay}}
@isExpanded={{this.displaySectionContent}}
@isActive={{this.isActive}}
>
{{#if @collapsable}}
<span class="sidebar-section-header-caret">

View File

@ -81,8 +81,6 @@ class SidebarAdminSectionLink extends BaseCustomSidebarSectionLink {
return this.router.currentRoute.name;
}
}
return this.adminSidebarNavLink.route;
}
get keywords() {
@ -264,6 +262,8 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel {
key = ADMIN_PANEL;
hidden = true;
displayHeader = true;
expandActiveSection = true;
scrollActiveLinkIntoView = true;
@cached
get sections() {

View File

@ -57,6 +57,14 @@ export default class BaseCustomSidebarPanel {
return false;
}
get expandActiveSection() {
return false;
}
get scrollActiveLinkIntoView() {
return false;
}
/**
* @param {string} filter filter applied
*

View File

@ -36,6 +36,11 @@ export default class BaseCustomSidebarSectionLink {
*/
get models() {}
/**
* @returns {boolean} `query` argument for the <LinkTo> component. See See https://api.emberjs.com/ember/release/classes/Ember.Templates.components/methods/LinkTo?anchor=LinkTo.
*/
get query() {}
/**
* @returns {boolean} `current-when` argument for the <LinkTo> component. See See https://api.emberjs.com/ember/release/classes/Ember.Templates.components/methods/LinkTo?anchor=LinkTo.
*/

View File

@ -28,6 +28,7 @@ export default class SidebarState extends Service {
@tracked isForcingAdminSidebar = false;
panels = panels;
activeExpandedSections = new TrackedSet();
collapsedSections = new TrackedSet();
previousState = {};
#hiders = new TrackedSet();
@ -87,6 +88,8 @@ export default class SidebarState extends Service {
getCollapsedSidebarSectionKey(sectionKey);
this.keyValueStore.setItem(collapsedSidebarSectionKey, true);
this.collapsedSections.add(collapsedSidebarSectionKey);
// remove the section from the active expanded list if collapsed later
this.activeExpandedSections.delete(sectionKey);
}
expandSection(sectionKey) {
@ -94,6 +97,8 @@ export default class SidebarState extends Service {
getCollapsedSidebarSectionKey(sectionKey);
this.keyValueStore.setItem(collapsedSidebarSectionKey, false);
this.collapsedSections.delete(collapsedSidebarSectionKey);
// remove the section from the active expanded list if expanded later
this.activeExpandedSections.delete(sectionKey);
}
isCurrentPanel(panel) {

View File

@ -1031,4 +1031,193 @@ acceptance("Sidebar - Plugin API", function (needs) {
.dom(".sidebar-section[data-section-name='test-admin-section']")
.doesNotExist();
});
test("Auto expand active sections", async function (assert) {
withPluginApi(PLUGIN_API_VERSION, (api) => {
api.addSidebarPanel((BaseCustomSidebarPanel) => {
return class extends BaseCustomSidebarPanel {
key = "new-panel";
expandActiveSection = true;
};
});
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
return class extends BaseCustomSidebarSection {
name = "test-section-1";
text = "The First Section";
collapsedByDefault = true;
get links() {
return [
new (class extends BaseCustomSidebarSectionLink {
get name() {
return `test-link-1`;
}
get href() {
return `/test1`;
}
get title() {
return `Test Link Title 1`;
}
get text() {
return `Test Link Text 1`;
}
})(),
];
}
};
},
"new-panel"
);
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
return class extends BaseCustomSidebarSection {
name = "test-section-2";
text = "The Second Section";
collapsedByDefault = true;
get links() {
return [
new (class extends BaseCustomSidebarSectionLink {
get name() {
return `search`;
}
get route() {
return `full-page-search`;
}
get title() {
return `Search`;
}
get text() {
return `Search`;
}
})(),
];
}
};
},
"new-panel"
);
api.setSeparatedSidebarMode();
api.setSidebarPanel("new-panel");
});
await visit("/");
assert.dom(".sidebar-section.sidebar-section--expanded").doesNotExist();
await visit("/search");
assert
.dom(
"div[data-section-name='test-section-2'].sidebar-section.sidebar-section--expanded"
)
.exists({ count: 1 });
});
test("Scroll active link into view", async function (assert) {
withPluginApi(PLUGIN_API_VERSION, (api) => {
api.addSidebarPanel((BaseCustomSidebarPanel) => {
return class extends BaseCustomSidebarPanel {
key = "new-panel";
expandActiveSection = true;
scrollActiveLinkIntoView = true;
};
});
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
return class extends BaseCustomSidebarSection {
name = `test-section-1`;
text = "The Section";
collapsedByDefault = false;
get links() {
const values = [...Array(100)].map(
(_, i) =>
new (class extends BaseCustomSidebarSectionLink {
get name() {
return `test-link-${i}`;
}
get href() {
return `/test${i}`;
}
get title() {
return `Test Link Title ${i}`;
}
get text() {
return `Test Link Text ${i}`;
}
})()
);
values.push(
new (class extends BaseCustomSidebarSectionLink {
get name() {
return `search`;
}
get route() {
return `full-page-search`;
}
get title() {
return `Search`;
}
get text() {
return `Search`;
}
})()
);
return values;
}
};
},
"new-panel"
);
api.setSeparatedSidebarMode();
api.setSidebarPanel("new-panel");
});
await visit("/");
const sidebarHeight = query(".sidebar-wrapper").clientHeight;
const searchLinkOffsetTop = query(
".sidebar-section-link-wrapper[data-list-item-name='search']"
).offsetTop;
assert.ok(
searchLinkOffsetTop > sidebarHeight,
"the link offsetTop is greater than the sidebar height"
);
assert.strictEqual(
query(".sidebar-sections").scrollTop,
0,
"the sidebar is not scrolled initially"
);
await visit("/search");
assert
.dom(
".sidebar-section-link-wrapper[data-list-item-name='search'] > a.active"
)
.exists();
const sidebarScrollTop = query(".sidebar-sections").scrollTop;
assert.ok(
sidebarScrollTop > 0,
"the sidebar was scrolled to position the active element into view"
);
assert.ok(
searchLinkOffsetTop < sidebarScrollTop + sidebarHeight,
"the link is into view"
);
});
});