DEV: Auto expand active sections and scroll active link into view (#28237)
This commit is contained in:
parent
064332ef6e
commit
a9abaf408d
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -57,6 +57,14 @@ export default class BaseCustomSidebarPanel {
|
|||
return false;
|
||||
}
|
||||
|
||||
get expandActiveSection() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get scrollActiveLinkIntoView() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filter filter applied
|
||||
*
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue