DEV: Convert header to glimmer (#25214)
Here is a breakdown of the changes that will be implemented in this PR. # Widgets -> Glimmer Obviously, the intention of the todo here is to convert the header from widgets to glimmer. This PR splits the respective widgets as so: ### widgets/site-header.js ```mermaid height=200 flowchart TB A[widgets/site-header.js] A-->B[components/glimmer-site-header.gjs] ``` ### widgets/header.js and children ```mermaid height=200 flowchart TB A[widgets/header.js] A-->B[components/glimmer-header.gjs] B-->C[glimmer-header/contents.gjs] C-->D[./auth-buttons.gjs] C-->E[./icons.gjs] C-->F[./user-menu-wrapper.gjs] C-->G[./hamburger-dropdown-wrapper.gjs] C-->H[./user-menu-wrapper.gjs] C-->I[./sidebar-toggle.gjs] C-->J[./topic/info.gjs] ``` There are additional components rendered within the `glimmer-header/*` components, but I will leave those out for now. From this view you can see that we split apart the logic of `widgets/header.js` into 10+ components. Breaking apart these mega files has many benefits (readability, etc). # Services I have introduced a [header](cdb42caa04/app/assets/javascripts/discourse/app/services/header.js
) service. This simplifies how we pass around data in the header, as well as fixes a bug we have with "swiping" menu panels. # Modifiers Added a [close-on-click-outside](cdb42caa04/app/assets/javascripts/discourse/app/modifiers/close-on-click-outside.js
) modifier that is built upon the [close-on-click-outside modifier](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/float-kit/addon/modifiers/close-on-click-outside.js) that @jjaffeux built for float-kit. I think we could replace float-kit's implementation with mine and have it in a centralized location as they are extremely similar. # Tests Rewrote the existing header tests ([1](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/tests/integration/components/widgets/header-test.js), [2](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js)) as system tests. # Other - Converted `widgets/user-status-bubble.js` to a gjs component - Converted `widgets/sidebar-toggle.js` to a gjs component - Converted `topicFeaturedLinkNode()` to a gjs component - Deprecated the [docking mixin](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/app/mixins/docking.js)
This commit is contained in:
parent
d10b1aaedd
commit
21f23cc032
|
@ -2,6 +2,7 @@ import { getOwner } from "@ember/application";
|
|||
import Component from "@ember/component";
|
||||
import { alias } from "@ember/object/computed";
|
||||
import { schedule, scheduleOnce, throttle } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { isBlank } from "@ember/utils";
|
||||
import $ from "jquery";
|
||||
import ClickTrack from "discourse/lib/click-track";
|
||||
|
@ -23,6 +24,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
|
|||
"topic.category.read_restricted:read_restricted",
|
||||
"topic.deleted:deleted-topic",
|
||||
],
|
||||
header: service(),
|
||||
menuVisible: true,
|
||||
SHORT_POST: 1200,
|
||||
|
||||
|
@ -54,6 +56,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
|
|||
|
||||
_hideTopicInHeader() {
|
||||
this.appEvents.trigger("header:hide-topic");
|
||||
this.header.topic = null;
|
||||
this._lastShowTopic = false;
|
||||
},
|
||||
|
||||
|
@ -62,6 +65,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
|
|||
return;
|
||||
}
|
||||
this.appEvents.trigger("header:show-topic", topic);
|
||||
this.header.topic = topic;
|
||||
this._lastShowTopic = true;
|
||||
},
|
||||
|
||||
|
|
|
@ -0,0 +1,225 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import scrollLock from "discourse/lib/scroll-lock";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import { scrollTop } from "discourse/mixins/scroll-top";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
import AuthButtons from "./glimmer-header/auth-buttons";
|
||||
import Contents from "./glimmer-header/contents";
|
||||
import HamburgerDropdownWrapper from "./glimmer-header/hamburger-dropdown-wrapper";
|
||||
import Icons from "./glimmer-header/icons";
|
||||
import SearchMenuWrapper from "./glimmer-header/search-menu-wrapper";
|
||||
import UserMenuWrapper from "./glimmer-header/user-menu-wrapper";
|
||||
|
||||
const SEARCH_BUTTON_ID = "search-button";
|
||||
|
||||
let _customHeaderClasses = [];
|
||||
export function addCustomHeaderClass(className) {
|
||||
_customHeaderClasses.push(className);
|
||||
}
|
||||
|
||||
export default class GlimmerHeader extends Component {
|
||||
@service router;
|
||||
@service search;
|
||||
@service currentUser;
|
||||
@service site;
|
||||
@service appEvents;
|
||||
@service register;
|
||||
@service header;
|
||||
|
||||
@tracked skipSearchContext = this.site.mobileView;
|
||||
@tracked panelElement;
|
||||
|
||||
appEventsListeners = modifier(() => {
|
||||
this.appEvents.on(
|
||||
"header:keyboard-trigger",
|
||||
this,
|
||||
this.headerKeyboardTrigger
|
||||
);
|
||||
return () => {
|
||||
this.appEvents.off(
|
||||
"header:keyboard-trigger",
|
||||
this,
|
||||
this.headerKeyboardTrigger
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
get customHeaderClasses() {
|
||||
return _customHeaderClasses.join(" ");
|
||||
}
|
||||
|
||||
@action
|
||||
headerKeyboardTrigger(msg) {
|
||||
switch (msg.type) {
|
||||
case "search":
|
||||
this.toggleSearchMenu();
|
||||
break;
|
||||
case "user":
|
||||
this.toggleUserMenu();
|
||||
break;
|
||||
case "hamburger":
|
||||
this.toggleHamburger();
|
||||
break;
|
||||
case "page-search":
|
||||
if (!this.togglePageSearch()) {
|
||||
msg.event.preventDefault();
|
||||
msg.event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleSearchMenu() {
|
||||
if (this.site.mobileView) {
|
||||
const context = this.search.searchContext;
|
||||
let params = "";
|
||||
if (context) {
|
||||
params = `?context=${context.type}&context_id=${context.id}&skip_context=${this.skipSearchContext}`;
|
||||
}
|
||||
|
||||
if (this.router.currentRouteName === "full-page-search") {
|
||||
scrollTop();
|
||||
document.querySelector(".full-page-search").focus();
|
||||
return false;
|
||||
} else {
|
||||
return DiscourseURL.routeTo("/search" + params);
|
||||
}
|
||||
}
|
||||
|
||||
this.search.visible = !this.search.visible;
|
||||
if (!this.search.visible) {
|
||||
this.search.highlightTerm = "";
|
||||
this.search.inTopicContext = false;
|
||||
document.getElementById(SEARCH_BUTTON_ID)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
togglePageSearch() {
|
||||
this.search.inTopicContext = false;
|
||||
|
||||
let showSearch = this.router.currentRouteName.startsWith("topic.");
|
||||
// If we're viewing a topic, only intercept search if there are cloaked posts
|
||||
if (showSearch) {
|
||||
const controller = this.register.lookup("controller:topic");
|
||||
const total = controller.get("model.postStream.stream.length") || 0;
|
||||
const chunkSize = controller.get("model.chunk_size") || 0;
|
||||
showSearch =
|
||||
total > chunkSize &&
|
||||
document.querySelector(
|
||||
".topic-post .cooked, .small-action:not(.time-gap)"
|
||||
)?.length < total;
|
||||
}
|
||||
|
||||
if (this.search.visible) {
|
||||
this.toggleSearchMenu();
|
||||
return showSearch;
|
||||
}
|
||||
|
||||
if (showSearch) {
|
||||
this.search.inTopicContext = true;
|
||||
this.toggleSearchMenu();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleUserMenu() {
|
||||
this.header.userVisible = !this.header.userVisible;
|
||||
this.toggleBodyScrolling(this.header.userVisible);
|
||||
this.args.animateMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
toggleHamburger() {
|
||||
if (this.args.sidebarEnabled && !this.site.narrowDesktopView) {
|
||||
this.args.toggleSidebar();
|
||||
this.args.animateMenu();
|
||||
} else {
|
||||
this.header.hamburgerVisible = !this.header.hamburgerVisible;
|
||||
this.toggleBodyScrolling(this.header.hamburgerVisible);
|
||||
this.args.animateMenu();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleBodyScrolling(bool) {
|
||||
if (!this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
scrollLock(bool);
|
||||
}
|
||||
|
||||
@action
|
||||
setPanelElement(element) {
|
||||
this.panelElement = element;
|
||||
}
|
||||
|
||||
<template>
|
||||
<header
|
||||
class={{concatClass this.customHeaderClasses "d-header"}}
|
||||
{{this.appEventsListeners}}
|
||||
>
|
||||
<div class="wrap">
|
||||
<Contents
|
||||
@sidebarEnabled={{@sidebarEnabled}}
|
||||
@toggleHamburger={{this.toggleHamburger}}
|
||||
@showSidebar={{@showSidebar}}
|
||||
>
|
||||
{{#unless this.currentUser}}
|
||||
<AuthButtons
|
||||
@showCreateAccount={{@showCreateAccount}}
|
||||
@showLogin={{@showLogin}}
|
||||
@canSignUp={{@canSignUp}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{#if
|
||||
(not (and this.siteSettings.login_required (not this.currentUser)))
|
||||
}}
|
||||
<Icons
|
||||
@sidebarEnabled={{@sidebarEnabled}}
|
||||
@toggleSearchMenu={{this.toggleSearchMenu}}
|
||||
@toggleHamburger={{this.toggleHamburger}}
|
||||
@toggleUserMenu={{this.toggleUserMenu}}
|
||||
@searchButtonId={{SEARCH_BUTTON_ID}}
|
||||
@panelElement={{this.panelElement}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.search.visible}}
|
||||
<SearchMenuWrapper @closeSearchMenu={{this.toggleSearchMenu}} />
|
||||
{{else if this.header.hamburgerVisible}}
|
||||
<HamburgerDropdownWrapper
|
||||
@toggleHamburger={{this.toggleHamburger}}
|
||||
/>
|
||||
{{else if this.header.userVisible}}
|
||||
<UserMenuWrapper @toggleUserMenu={{this.toggleUserMenu}} />
|
||||
{{/if}}
|
||||
|
||||
<div id="additional-panel-wrapper" {{didInsert this.setPanelElement}}>
|
||||
</div>
|
||||
|
||||
{{#if
|
||||
(and
|
||||
(or this.site.mobileView this.site.narrowDesktopView)
|
||||
(or this.header.hamburgerVisible this.header.userVisible)
|
||||
)
|
||||
}}
|
||||
<div class="header-cloak"></div>
|
||||
{{/if}}
|
||||
</Contents>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
|
||||
export default class AuthButtons extends Component {
|
||||
@service header;
|
||||
|
||||
<template>
|
||||
<span class="header-buttons">
|
||||
{{#if (and @canSignUp (not this.header.topic))}}
|
||||
<DButton
|
||||
class="btn-primary btn-small sign-up-button"
|
||||
@action={{@showCreateAccount}}
|
||||
@label="sign_up"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<DButton
|
||||
class="btn-primary btn-small login-button"
|
||||
@action={{@showLogin}}
|
||||
@label="log_in"
|
||||
@icon="user"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { inject as service } from "@ember/service";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import BootstrapModeNotice from "../bootstrap-mode-notice";
|
||||
import MountWidget from "../mount-widget";
|
||||
import PluginOutlet from "../plugin-outlet";
|
||||
import SidebarToggle from "./sidebar-toggle";
|
||||
import TopicInfo from "./topic/info";
|
||||
|
||||
export default class Contents extends Component {
|
||||
@service site;
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
@service header;
|
||||
|
||||
get topicPresent() {
|
||||
return !!this.header.topic;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="contents">
|
||||
{{#if this.site.desktopView}}
|
||||
{{#if @sidebarEnabled}}
|
||||
<SidebarToggle @toggleHamburger={{@toggleHamburger}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<div class="home-logo-wrapper-outlet">
|
||||
<PluginOutlet @name="home-logo-wrapper">
|
||||
<MountWidget
|
||||
@widget="home-logo"
|
||||
@args={{hash minimized=this.topicPresent}}
|
||||
/>
|
||||
</PluginOutlet>
|
||||
</div>
|
||||
|
||||
{{#if this.header.topic}}
|
||||
<TopicInfo @topic={{this.header.topic}} />
|
||||
{{else if
|
||||
(and
|
||||
this.siteSettings.bootstrap_mode_enabled
|
||||
this.currentUser.staff
|
||||
this.site.desktopView
|
||||
)
|
||||
}}
|
||||
<div class="d-header-mode">
|
||||
<BootstrapModeNotice />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="before-header-panel-outlet">
|
||||
<PluginOutlet
|
||||
@name="before-header-panel"
|
||||
@outletArgs={{hash topic=this.header.topic}}
|
||||
/>
|
||||
</div>
|
||||
<div class="panel" role="navigation">{{yield}}</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import closeOnClickOutside from "../../modifiers/close-on-click-outside";
|
||||
|
||||
export default class Dropdown extends Component {
|
||||
@action
|
||||
click(e) {
|
||||
if (wantsNewWindow(e)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.args.onClick(e);
|
||||
|
||||
// remove the focus of the header dropdown button after clicking
|
||||
e.target.tagName.toLowerCase() === "button"
|
||||
? e.target.blur()
|
||||
: e.target.closest("button").blur();
|
||||
}
|
||||
|
||||
<template>
|
||||
<li
|
||||
class={{concatClass
|
||||
@className
|
||||
(if @active "active")
|
||||
"header-dropdown-toggle"
|
||||
}}
|
||||
{{(if
|
||||
(and @active @targetSelector)
|
||||
(modifier
|
||||
closeOnClickOutside @onClick (hash targetSelector=@targetSelector)
|
||||
)
|
||||
)}}
|
||||
>
|
||||
<button
|
||||
class="button icon btn-flat"
|
||||
aria-expanded={{@active}}
|
||||
aria-haspopup="true"
|
||||
href={{@href}}
|
||||
data-auto-route="true"
|
||||
title={{i18n @title}}
|
||||
aria-label={{i18n @title}}
|
||||
id={{@iconId}}
|
||||
{{on "click" this.click}}
|
||||
>
|
||||
{{icon @icon}}
|
||||
{{@contents}}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { prefersReducedMotion } from "discourse/lib/utilities";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import closeOnClickOutside from "../../modifiers/close-on-click-outside";
|
||||
import HamburgerDropdown from "../sidebar/hamburger-dropdown";
|
||||
|
||||
export default class HamburgerDropdownWrapper extends Component {
|
||||
@action
|
||||
click(e) {
|
||||
e.preventDefault();
|
||||
if (
|
||||
e.target.closest(
|
||||
".sidebar-section-header-button, .sidebar-section-link-button, .sidebar-section-link"
|
||||
)
|
||||
) {
|
||||
this.args.toggleHamburger();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
clickOutside(e) {
|
||||
if (
|
||||
e.target.classList.contains("header-cloak") &&
|
||||
!prefersReducedMotion()
|
||||
) {
|
||||
const panel = document.querySelector(".menu-panel");
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
const finishPosition =
|
||||
document.querySelector("html").classList["direction"] === "rtl"
|
||||
? "340px"
|
||||
: "-340px";
|
||||
panel
|
||||
.animate([{ transform: `translate3d(${finishPosition}, 0, 0)` }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
})
|
||||
.finished.then(() => {
|
||||
if (isTesting()) {
|
||||
this.args.toggleHamburger();
|
||||
} else {
|
||||
discourseLater(() => this.args.toggleHamburger());
|
||||
}
|
||||
});
|
||||
headerCloak.animate([{ opacity: 0 }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
});
|
||||
} else {
|
||||
this.args.toggleHamburger();
|
||||
}
|
||||
}
|
||||
<template>
|
||||
<div
|
||||
class="hamburger-dropdown-wrapper"
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
{{on "click" this.click}}
|
||||
{{! we don't want to close the hamburger dropdown when clicking on the hamburger dropdown itself
|
||||
so we use the secondaryTargetSelector to prevent that }}
|
||||
{{closeOnClickOutside
|
||||
this.clickOutside
|
||||
(hash
|
||||
targetSelector=".hamburger-panel"
|
||||
secondaryTargetSelector=".hamburger-dropdown"
|
||||
)
|
||||
}}
|
||||
>
|
||||
<HamburgerDropdown />
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
import MountWidget from "../mount-widget";
|
||||
import Dropdown from "./dropdown";
|
||||
import PanelPortal from "./panel-portal";
|
||||
import UserDropdown from "./user-dropdown";
|
||||
|
||||
let _extraHeaderIcons = [];
|
||||
export function addToHeaderIcons(icon) {
|
||||
_extraHeaderIcons.push(icon);
|
||||
}
|
||||
|
||||
export function clearExtraHeaderIcons() {
|
||||
_extraHeaderIcons = [];
|
||||
}
|
||||
|
||||
export default class Icons extends Component {
|
||||
@service site;
|
||||
@service currentUser;
|
||||
@service header;
|
||||
@service search;
|
||||
|
||||
_isStringType = (icon) => typeof icon === "string";
|
||||
|
||||
<template>
|
||||
<ul class="icons d-header-icons">
|
||||
{{#each _extraHeaderIcons as |Icon|}}
|
||||
{{#if (this._isStringType Icon)}}
|
||||
<MountWidget @widget={{Icon}} />
|
||||
{{else}}
|
||||
{{#let
|
||||
(component PanelPortal panelElement=@panelElement)
|
||||
as |panelPortal|
|
||||
}}
|
||||
<Icon @panelPortal={{panelPortal}} />
|
||||
{{/let}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
<Dropdown
|
||||
@title="search.title"
|
||||
@icon="search"
|
||||
@iconId={{@searchButtonId}}
|
||||
@onClick={{@toggleSearchMenu}}
|
||||
@active={{this.search.visible}}
|
||||
@href={{getURL "/search"}}
|
||||
@className="search-dropdown"
|
||||
@targetSelector=".search-menu-panel"
|
||||
/>
|
||||
|
||||
{{#if (or (not @sidebarEnabled) this.site.mobileView)}}
|
||||
<Dropdown
|
||||
@title="hamburger_menu"
|
||||
@icon="bars"
|
||||
@iconId="toggle-hamburger-menu"
|
||||
@active={{this.header.hamburgerVisible}}
|
||||
@onClick={{@toggleHamburger}}
|
||||
@className="hamburger-dropdown"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.currentUser}}
|
||||
<UserDropdown
|
||||
@active={{this.header.userVisible}}
|
||||
@toggleUserMenu={{@toggleUserMenu}}
|
||||
/>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import ConditionalInElement from "../conditional-in-element";
|
||||
|
||||
const PanelPortal = <template>
|
||||
<ConditionalInElement @element={{@panelElement}}>
|
||||
{{yield}}
|
||||
</ConditionalInElement>
|
||||
</template>;
|
||||
|
||||
export default PanelPortal;
|
|
@ -0,0 +1,9 @@
|
|||
import SearchMenuPanel from "../search-menu-panel";
|
||||
|
||||
const SearchMenuWrapper = <template>
|
||||
<div class="search-menu glimmer-search-menu" aria-live="polite">
|
||||
<SearchMenuPanel @closeSearchMenu={{@closeSearchMenu}} />
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default SearchMenuWrapper;
|
|
@ -0,0 +1,37 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
|
||||
export default class SidebarToggle extends Component {
|
||||
@service site;
|
||||
|
||||
@action
|
||||
toggleWithBlur(e) {
|
||||
this.args.toggleHamburger();
|
||||
// remove the focus of the header dropdown button after clicking
|
||||
e.target.tagName.toLowerCase() === "button"
|
||||
? e.target.blur()
|
||||
: e.target.closest("button").blur();
|
||||
}
|
||||
|
||||
<template>
|
||||
<span class="header-sidebar-toggle">
|
||||
<button
|
||||
title={{i18n "sidebar.title"}}
|
||||
class={{concatClass
|
||||
"btn btn-flat btn-sidebar-toggle no-text btn-icon"
|
||||
(if this.site.narrowDesktopView "narrow-desktop")
|
||||
}}
|
||||
aria-expanded={{if @showSidebar "true" "false"}}
|
||||
aria-controls="d-sidebar"
|
||||
{{on "click" this.toggleWithBlur}}
|
||||
>
|
||||
{{icon "bars"}}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { extractLinkMeta } from "discourse/lib/render-topic-featured-link";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
|
||||
export default class FeaturedLink extends Component {
|
||||
@service header;
|
||||
|
||||
get meta() {
|
||||
return extractLinkMeta(this.header.topic);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.meta}}
|
||||
<a
|
||||
class="topic-featured-link"
|
||||
rel={{this.meta.rel}}
|
||||
target={{this.meta.target}}
|
||||
href={{this.meta.href}}
|
||||
>
|
||||
{{icon "external-link-alt"}}
|
||||
{{this.meta.domain}}
|
||||
</a>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { fn, hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import categoryLink from "discourse/helpers/category-link";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import renderTags from "discourse/lib/render-tags";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import gt from "truth-helpers/helpers/gt";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
import PluginOutlet from "../../plugin-outlet";
|
||||
import FeaturedLink from "./featured-link";
|
||||
import Participant from "./participant";
|
||||
import Status from "./status";
|
||||
|
||||
export default class Info extends Component {
|
||||
@service currentUser;
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
|
||||
get showPM() {
|
||||
return !this.args.topic.is_warning && this.args.topic.isPrivateMessage;
|
||||
}
|
||||
|
||||
get totalParticipants() {
|
||||
return (
|
||||
(this.args.topic.details.allowed_users?.length || 0) +
|
||||
(this.args.topic.allowed_groups?.length || 0)
|
||||
);
|
||||
}
|
||||
|
||||
get maxExtraItems() {
|
||||
return this.args.topic.tags?.length > 0 ? 5 : 10;
|
||||
}
|
||||
|
||||
get twoRows() {
|
||||
return (
|
||||
this.tags?.length ||
|
||||
this.showPM ||
|
||||
this.siteSettings.topic_featured_link_enabled
|
||||
);
|
||||
}
|
||||
|
||||
get tags() {
|
||||
if (this.args.topic.tags) {
|
||||
return renderTags(this.args.topic);
|
||||
}
|
||||
}
|
||||
|
||||
get remainingParticipantCount() {
|
||||
return this.totalParticipants - this.maxExtraItems;
|
||||
}
|
||||
|
||||
get participants() {
|
||||
const participants = [
|
||||
...this.args.topic.details.allowed_users,
|
||||
...this.args.topic.details.allowed_groups,
|
||||
];
|
||||
return participants.slice(0, this.maxExtraItems);
|
||||
}
|
||||
|
||||
@action
|
||||
jumpToTopPost(e) {
|
||||
e.preventDefault();
|
||||
if (this.args.topic) {
|
||||
DiscourseURL.routeTo(this.args.topic.firstPostUrl, {
|
||||
keepFilter: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class={{concatClass (if this.twoRows "two-rows") "extra-info-wrapper"}}
|
||||
>
|
||||
<div class={{concatClass (if this.twoRows "two-rows") "extra-info"}}>
|
||||
<div class="title-wrapper">
|
||||
<h1 class="header-title">
|
||||
{{#if this.showPM}}
|
||||
<a
|
||||
class="private-message-glyph-wrapper"
|
||||
href={{fn this.currentUser.pmPath @topic}}
|
||||
aria-label={{i18n "user.messages.inbox"}}
|
||||
>
|
||||
{{icon "envelope" class="private-message-glyph"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and @topic.fancyTitle @topic.url)}}
|
||||
<Status @topic={{@topic}} @disableActions={{@disableActions}} />
|
||||
|
||||
<a
|
||||
class="topic-link"
|
||||
{{on "click" this.jumpToTopPost}}
|
||||
href={{@topic.url}}
|
||||
data-topic-id={{@topic.id}}
|
||||
>
|
||||
<span>{{htmlSafe @topic.fancyTitle}}</span>
|
||||
</a>
|
||||
|
||||
<span class="header-topic-title-suffix">
|
||||
<PluginOutlet
|
||||
@name="header-topic-title-suffix"
|
||||
@outletArgs={{hash topic=@topic}}
|
||||
/>
|
||||
</span>
|
||||
{{/if}}
|
||||
</h1>
|
||||
|
||||
{{#if (or @topic.details.loaded @topic.category)}}
|
||||
{{#if
|
||||
(and
|
||||
@topic.category
|
||||
(or
|
||||
(not @topic.category.isUncategorizedCategory)
|
||||
(not this.siteSettings.suppress_uncategorized_badge)
|
||||
)
|
||||
)
|
||||
}}
|
||||
<div class="categories-wrapper">
|
||||
{{#if @topic.category.parentCategory}}
|
||||
{{#if
|
||||
(and
|
||||
@topic.category.parentCategory.parentCategory
|
||||
(not this.site.mobileView)
|
||||
)
|
||||
}}
|
||||
{{categoryLink
|
||||
@topic.category.parentCategory.parentCategory
|
||||
}}
|
||||
{{/if}}
|
||||
|
||||
{{categoryLink
|
||||
@topic.category.parentCategory
|
||||
(hash hideParent="true")
|
||||
}}
|
||||
{{/if}}
|
||||
{{categoryLink @topic.category}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="topic-header-extra">
|
||||
{{htmlSafe this.tags}}
|
||||
<div class="topic-header-participants">
|
||||
{{#if this.showPM}}
|
||||
{{#each this.participants as |participant|}}
|
||||
<Participant
|
||||
@user={{participant}}
|
||||
@type={{if participant.username "user" "group"}}
|
||||
{{! username for user, name for group }}
|
||||
@username={{or participant.username participant.name}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
{{#if (gt this.totalParticipants this.maxExtraItems)}}
|
||||
<a
|
||||
class="more-participants"
|
||||
{{on "click" this.jumpToTopPost}}
|
||||
href={{@topic.url}}
|
||||
data-topic-id={{@topic.id}}
|
||||
>
|
||||
+{{this.remainingParticipantCount}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.siteSettings.topic_featured_link_enabled}}
|
||||
<FeaturedLink />
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { concat, hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import avatar from "discourse/helpers/bound-avatar-template";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
import eq from "truth-helpers/helpers/eq";
|
||||
|
||||
export default class Participant extends Component {
|
||||
@service appEvents;
|
||||
|
||||
get url() {
|
||||
return this.args.type === "user"
|
||||
? this.args.user.path
|
||||
: getURL(`/g/${this.args.username}`);
|
||||
}
|
||||
|
||||
@action
|
||||
click(e) {
|
||||
this.appEvents.trigger(
|
||||
`topic-header:trigger-${this.args.type}-card`,
|
||||
this.args.username,
|
||||
e.target
|
||||
);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
<template>
|
||||
<span class={{concat "trigger-" @type "-card"}}>
|
||||
<a
|
||||
class="icon"
|
||||
{{on "click" this.click}}
|
||||
href={{this.url}}
|
||||
data-auto-route="true"
|
||||
title={{@username}}
|
||||
>
|
||||
{{#if (eq @type "user")}}
|
||||
{{avatar @user.avatar_template "tiny" (hash title=@username)}}
|
||||
{{else}}
|
||||
<span>
|
||||
{{icon "users"}}
|
||||
{{@username}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import TopicStatusIcons from "discourse/helpers/topic-status-icons";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
export default class Status extends Component {
|
||||
@service currentUser;
|
||||
|
||||
get canAct() {
|
||||
return this.currentUser && !this.args.disableActions;
|
||||
}
|
||||
|
||||
get topicStatuses() {
|
||||
let topicStatuses = [];
|
||||
TopicStatusIcons.render(this.args.topic, (name, key) => {
|
||||
const iconArgs = { class: key === "unpinned" ? "unpinned" : null };
|
||||
const statusIcon = { name, iconArgs };
|
||||
|
||||
const attributes = {
|
||||
title: escapeExpression(I18n.t(`topic_statuses.${key}.help`)),
|
||||
};
|
||||
let klass = ["topic-status"];
|
||||
if (key === "unpinned" || key === "pinned") {
|
||||
klass.push("pin-toggle-button", key);
|
||||
klass = klass.join(" ");
|
||||
}
|
||||
topicStatuses.push({ attributes, klass, icon: statusIcon });
|
||||
});
|
||||
|
||||
return topicStatuses;
|
||||
}
|
||||
|
||||
@action
|
||||
togglePinnedForUser(e) {
|
||||
if (!this.canAct) {
|
||||
return;
|
||||
}
|
||||
const parent = e.target.closest(".topic-statuses");
|
||||
if (parent?.querySelector(".pin-toggle-button")?.contains(e.target)) {
|
||||
this.args.topic.togglePinnedForUser();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<span class="topic-statuses">
|
||||
{{#each this.topicStatuses as |status|}}
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<span
|
||||
class={{concatClass status.klass "topic-status"}}
|
||||
{{on "click" this.togglePinnedForUser}}
|
||||
>
|
||||
{{icon status.icon.name class=status.icon.iconArgs.class}}
|
||||
</span>
|
||||
{{/each}}
|
||||
</span>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
import Notifications from "./user-dropdown/notifications";
|
||||
|
||||
export default class UserDropdown extends Component {
|
||||
@service currentUser;
|
||||
|
||||
@action
|
||||
click(e) {
|
||||
if (wantsNewWindow(e)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.args.toggleUserMenu();
|
||||
|
||||
// remove the focus of the header dropdown button after clicking
|
||||
e.target.tagName.toLowerCase() === "button"
|
||||
? e.target.blur()
|
||||
: e.target.closest("button").blur();
|
||||
}
|
||||
|
||||
<template>
|
||||
<li
|
||||
id="current-user"
|
||||
class={{concatClass
|
||||
(if @active "active")
|
||||
"header-dropdown-toggle current-user user-menu-panel"
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="icon btn-flat"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={{@active}}
|
||||
href={{this.currentUser.path}}
|
||||
aria-label={{concat
|
||||
(or this.currentUser.name this.currentUser.username)
|
||||
(i18n "user.account_possessive")
|
||||
}}
|
||||
data-auto-route="true"
|
||||
{{on "click" this.click}}
|
||||
>
|
||||
<Notifications @active={{@active}} />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import {
|
||||
addExtraUserClasses,
|
||||
renderAvatar,
|
||||
} from "discourse/helpers/user-avatar";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import UserTip from "../../user-tip";
|
||||
import UserStatusBubble from "./user-status-bubble";
|
||||
|
||||
export default class Notifications extends Component {
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
|
||||
avatarSize = "medium";
|
||||
|
||||
get avatar() {
|
||||
let avatarAttrs = {};
|
||||
addExtraUserClasses(this.currentUser, avatarAttrs);
|
||||
return htmlSafe(
|
||||
renderAvatar(this.currentUser, {
|
||||
imageSize: this.avatarSize,
|
||||
alt: "user.avatar.header_title",
|
||||
template: this.currentUser.avatar_template,
|
||||
username: this.currentUser.username,
|
||||
name: this.siteSettings.enable_names && this.currentUser.name,
|
||||
...avatarAttrs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get _shouldHighlightAvatar() {
|
||||
return (
|
||||
!this.currentUser.read_first_notification &&
|
||||
!this.currentUser.enforcedSecondFactor &&
|
||||
!this.args.active
|
||||
);
|
||||
}
|
||||
|
||||
get isInDoNotDisturb() {
|
||||
return this.currentUser.isInDoNotDisturb();
|
||||
}
|
||||
|
||||
<template>
|
||||
{{this.avatar}}
|
||||
|
||||
{{#if this._shouldHighlightAvatar}}
|
||||
<UserTip
|
||||
@id="first_notification"
|
||||
@triggerSelector=".header-dropdown-toggle.current-user"
|
||||
@placement="bottom-end"
|
||||
@titleText={{i18n "user_tips.first_notification.title"}}
|
||||
@contentText={{i18n "user_tips.first_notification.content"}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.currentUser.status}}
|
||||
<UserStatusBubble
|
||||
@timezone={{this.this.currentUser.user_option.timezone}}
|
||||
@status={{this.currentUser.status}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isInDoNotDisturb}}
|
||||
<div class="do-not-disturb-background">{{icon "moon"}}</div>
|
||||
{{else}}
|
||||
{{#if this.currentUser.new_personal_messages_notifications_count}}
|
||||
<a
|
||||
href="#"
|
||||
class="badge-notification with-icon new-pms"
|
||||
title={{i18n
|
||||
"notifications.tooltip.new_message_notification"
|
||||
(hash
|
||||
count=this.currentUser.new_personal_messages_notifications_count
|
||||
)
|
||||
}}
|
||||
aria-label={{i18n
|
||||
"notifications.tooltip.new_message_notification"
|
||||
(hash
|
||||
count=this.currentUser.new_personal_messages_notifications_count
|
||||
)
|
||||
}}
|
||||
>
|
||||
{{icon "envelope"}}
|
||||
</a>
|
||||
{{else if this.currentUser.unseen_reviewable_count}}
|
||||
<a
|
||||
href="#"
|
||||
class="badge-notification with-icon new-reviewables"
|
||||
title={{i18n
|
||||
"notifications.tooltip.new_reviewable"
|
||||
(hash count=this.currentUser.unseen_reviewable_count)
|
||||
}}
|
||||
aria-label={{i18n
|
||||
"notifications.tooltip.new_reviewable"
|
||||
(hash count=this.currentUser.unseen_reviewable_count)
|
||||
}}
|
||||
>
|
||||
{{icon "flag"}}
|
||||
</a>
|
||||
{{else if this.currentUser.all_unread_notifications_count}}
|
||||
<a
|
||||
href="#"
|
||||
class="badge-notification unread-notifications"
|
||||
title={{i18n
|
||||
"notifications.tooltip.regular"
|
||||
(hash count=this.currentUser.all_unread_notifications_count)
|
||||
}}
|
||||
aria-label={{i18n
|
||||
"user.notifications"
|
||||
(hash count=this.currentUser.all_unread_notifications_count)
|
||||
}}
|
||||
>
|
||||
{{this.currentUser.all_unread_notifications_count}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { hash } from "@ember/helper";
|
||||
import emoji from "discourse/helpers/emoji";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
const title = (description, endsAt, timezone) => {
|
||||
let content = description;
|
||||
if (endsAt) {
|
||||
const until = moment
|
||||
.tz(endsAt, timezone)
|
||||
.format(I18n.t("dates.long_date_without_year"));
|
||||
content += `\n${I18n.t("until")} ${until}`;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
const UserStatusBubble = <template>
|
||||
<div class="user-status-background">
|
||||
{{emoji
|
||||
@status.emoji
|
||||
(hash title=(title @status.description @status.ends_at @timezone))
|
||||
}}
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default UserStatusBubble;
|
|
@ -0,0 +1,60 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { prefersReducedMotion } from "discourse/lib/utilities";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import closeOnClickOutside from "../../modifiers/close-on-click-outside";
|
||||
import UserMenu from "../user-menu/menu";
|
||||
|
||||
export default class UserMenuWrapper extends Component {
|
||||
@action
|
||||
clickOutside(e) {
|
||||
if (
|
||||
e.target.classList.contains("header-cloak") &&
|
||||
!prefersReducedMotion()
|
||||
) {
|
||||
const panel = document.querySelector(".menu-panel");
|
||||
const headerCloak = document.querySelector(".header-cloak");
|
||||
const finishPosition =
|
||||
document.documentElement.classList["direction"] === "rtl"
|
||||
? "-340px"
|
||||
: "340px";
|
||||
panel
|
||||
.animate([{ transform: `translate3d(${finishPosition}, 0, 0)` }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
})
|
||||
.finished.then(() => {
|
||||
if (isTesting()) {
|
||||
this.args.toggleUserMenu();
|
||||
} else {
|
||||
discourseLater(() => this.args.toggleUserMenu());
|
||||
}
|
||||
});
|
||||
headerCloak.animate([{ opacity: 0 }], {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
easing: "ease-in",
|
||||
});
|
||||
} else {
|
||||
this.args.toggleUserMenu();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="user-menu-dropdown-wrapper"
|
||||
{{closeOnClickOutside
|
||||
this.clickOutside
|
||||
(hash
|
||||
targetSelector=".user-menu-panel"
|
||||
secondaryTargetSelector=".user-menu-panel"
|
||||
)
|
||||
}}
|
||||
>
|
||||
<UserMenu @closeUserMenu={{@toggleUserMenu}} />
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,502 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { DEBUG } from "@glimmer/env";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { cancel, schedule } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { waitForPromise } from "@ember/test-waiters";
|
||||
import ItsATrap from "@discourse/itsatrap";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import scrollLock from "discourse/lib/scroll-lock";
|
||||
import SwipeEvents from "discourse/lib/swipe-events";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { bind, debounce } from "discourse-common/utils/decorators";
|
||||
import GlimmerHeader from "./glimmer-header";
|
||||
|
||||
let _menuPanelClassesToForceDropdown = [];
|
||||
const PANEL_WIDTH = 340;
|
||||
|
||||
export default class GlimmerSiteHeader extends Component {
|
||||
@service appEvents;
|
||||
@service currentUser;
|
||||
@service site;
|
||||
@service header;
|
||||
|
||||
pxClosed;
|
||||
headerElement;
|
||||
docking;
|
||||
_dockedHeader = false;
|
||||
_animate = false;
|
||||
_headerWrap;
|
||||
_swipeMenuOrigin;
|
||||
_swipeEvents;
|
||||
_applicationElement;
|
||||
_resizeObserver;
|
||||
_docAt;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.docking = new Docking(this.dockCheck);
|
||||
|
||||
if (this.currentUser?.staff) {
|
||||
document.body.classList.add("staff");
|
||||
}
|
||||
|
||||
schedule("afterRender", () => this.animateMenu());
|
||||
}
|
||||
|
||||
get dropDownHeaderEnabled() {
|
||||
return !this.sidebarEnabled || this.site.narrowDesktopView;
|
||||
}
|
||||
|
||||
get leftMenuClass() {
|
||||
if (document.querySelector("html").classList["direction"] === "rtl") {
|
||||
return "user-menu";
|
||||
} else {
|
||||
return "hamburger-panel";
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
updateHeaderOffset() {
|
||||
// Safari likes overscolling the page (on both iOS and macOS).
|
||||
// This shows up as a negative value in window.scrollY.
|
||||
// We can use this to offset the headerWrap's top offset to avoid
|
||||
// jitteriness and bad positioning.
|
||||
const windowOverscroll = Math.min(0, window.scrollY);
|
||||
|
||||
// The headerWrap's top offset can also be a negative value on Safari,
|
||||
// because of the changing height of the viewport (due to the URL bar).
|
||||
// For our use case, it's best to ensure this is clamped to 0.
|
||||
const headerWrapTop = Math.max(
|
||||
0,
|
||||
Math.floor(this._headerWrap.getBoundingClientRect().top)
|
||||
);
|
||||
let offsetTop = headerWrapTop + windowOverscroll;
|
||||
|
||||
if (DEBUG && isTesting()) {
|
||||
offsetTop -= document
|
||||
.getElementById("ember-testing-container")
|
||||
.getBoundingClientRect().top;
|
||||
|
||||
offsetTop -= 1; // For 1px border on testing container
|
||||
}
|
||||
|
||||
const documentStyle = document.documentElement.style;
|
||||
const currentValue =
|
||||
parseInt(documentStyle.getPropertyValue("--header-offset"), 10) || 0;
|
||||
const newValue = this._headerWrap.offsetHeight + offsetTop;
|
||||
if (currentValue !== newValue) {
|
||||
documentStyle.setProperty("--header-offset", `${newValue}px`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
_onScroll() {
|
||||
schedule("afterRender", this.updateHeaderOffset);
|
||||
}
|
||||
|
||||
@action
|
||||
setupHeader() {
|
||||
this.appEvents.on("user-menu:rendered", this, this.animateMenu);
|
||||
if (this.dropDownHeaderEnabled) {
|
||||
this.appEvents.on(
|
||||
"sidebar-hamburger-dropdown:rendered",
|
||||
this,
|
||||
this.animateMenu
|
||||
);
|
||||
}
|
||||
|
||||
this._headerWrap = document.querySelector(".d-header-wrap");
|
||||
if (this._headerWrap) {
|
||||
schedule("afterRender", () => {
|
||||
this.headerElement = this._headerWrap.querySelector("header.d-header");
|
||||
this.updateHeaderOffset();
|
||||
document.documentElement.style.setProperty(
|
||||
"--header-top",
|
||||
`${this.headerElement.offsetTop}px`
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", this._onScroll, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
this._itsatrap = new ItsATrap(this.headerElement);
|
||||
const dirs = ["up", "down"];
|
||||
this._itsatrap.bind(dirs, (e) => this._handleArrowKeysNav(e));
|
||||
|
||||
this._resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
if (entry.contentRect) {
|
||||
const headerTop = this.headerElement?.offsetTop;
|
||||
document.documentElement.style.setProperty(
|
||||
"--header-top",
|
||||
`${headerTop}px`
|
||||
);
|
||||
this.updateHeaderOffset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._resizeObserver.observe(this._headerWrap);
|
||||
|
||||
this._swipeEvents = new SwipeEvents(this._headerWrap);
|
||||
if (this.site.mobileView) {
|
||||
this._swipeEvents.addTouchListeners();
|
||||
this._headerWrap.addEventListener("swipestart", this.onSwipeStart);
|
||||
this._headerWrap.addEventListener("swipeend", this.onSwipeEnd);
|
||||
this._headerWrap.addEventListener("swipecancel", this.onSwipeCancel);
|
||||
this._headerWrap.addEventListener("swipe", this.onSwipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleArrowKeysNav(event) {
|
||||
const activeTab = document.querySelector(
|
||||
".menu-tabs-container .btn.active"
|
||||
);
|
||||
if (activeTab) {
|
||||
let activeTabNumber = Number(
|
||||
document.activeElement.dataset.tabNumber || activeTab.dataset.tabNumber
|
||||
);
|
||||
const maxTabNumber =
|
||||
document.querySelectorAll(".menu-tabs-container .btn").length - 1;
|
||||
const isNext = event.key === "ArrowDown";
|
||||
let nextTab = isNext ? activeTabNumber + 1 : activeTabNumber - 1;
|
||||
if (isNext && nextTab > maxTabNumber) {
|
||||
nextTab = 0;
|
||||
}
|
||||
if (!isNext && nextTab < 0) {
|
||||
nextTab = maxTabNumber;
|
||||
}
|
||||
event.preventDefault();
|
||||
document
|
||||
.querySelector(
|
||||
`.menu-tabs-container .btn[data-tab-number='${nextTab}']`
|
||||
)
|
||||
.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
animateMenu() {
|
||||
const menuPanels = document.querySelectorAll(".menu-panel");
|
||||
|
||||
if (menuPanels.length === 0) {
|
||||
this._animate = this.site.mobileView || this.site.narrowDesktopView;
|
||||
return;
|
||||
}
|
||||
|
||||
let viewMode =
|
||||
this.site.mobileView || this.site.narrowDesktopView
|
||||
? "slide-in"
|
||||
: "drop-down";
|
||||
|
||||
menuPanels.forEach((panel) => {
|
||||
if (menuPanelContainsClass(panel)) {
|
||||
viewMode = "drop-down";
|
||||
this._animate = false;
|
||||
}
|
||||
|
||||
const cloakElement = document.querySelector(".header-cloak");
|
||||
|
||||
panel.classList.remove("drop-down");
|
||||
panel.classList.remove("slide-in");
|
||||
panel.classList.add(viewMode);
|
||||
|
||||
if (this._animate) {
|
||||
let animationFinished = null;
|
||||
let finalPosition = PANEL_WIDTH;
|
||||
this._swipeMenuOrigin = "right";
|
||||
if (
|
||||
(this.site.mobileView || this.site.narrowDesktopView) &&
|
||||
panel.parentElement.classList.contains(this.leftMenuClass)
|
||||
) {
|
||||
this._swipeMenuOrigin = "left";
|
||||
finalPosition = -PANEL_WIDTH;
|
||||
}
|
||||
animationFinished = panel.animate(
|
||||
[{ transform: `translate3d(${finalPosition}px, 0, 0)` }],
|
||||
{
|
||||
fill: "forwards",
|
||||
}
|
||||
).finished;
|
||||
|
||||
if (isTesting()) {
|
||||
waitForPromise(animationFinished);
|
||||
}
|
||||
|
||||
cloakElement.animate([{ opacity: 0 }], { fill: "forwards" });
|
||||
cloakElement.style.display = "block";
|
||||
|
||||
animationFinished.then(() => {
|
||||
if (isTesting()) {
|
||||
this._animateOpening(panel);
|
||||
} else {
|
||||
discourseLater(() => this._animateOpening(panel));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._animate = false;
|
||||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
dockCheck() {
|
||||
if (this._docAt === null) {
|
||||
if (!this.headerElement) {
|
||||
return;
|
||||
}
|
||||
this._docAt = this.headerElement.offsetTop;
|
||||
}
|
||||
|
||||
const main = (this._applicationElement ??=
|
||||
document.querySelector(".ember-application"));
|
||||
const offsetTop = main?.offsetTop ?? 0;
|
||||
const offset = window.pageYOffset - offsetTop;
|
||||
if (offset >= this._docAt) {
|
||||
if (!this._dockedHeader) {
|
||||
document.body.classList.add("docked");
|
||||
this._dockedHeader = true;
|
||||
}
|
||||
} else {
|
||||
if (this._dockedHeader) {
|
||||
document.body.classList.remove("docked");
|
||||
this._dockedHeader = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
_animateOpening(panel, event = null) {
|
||||
const cloakElement = document.querySelector(".header-cloak");
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
this.pxClosed / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
const timing = {
|
||||
duration: durationMs > 0 ? durationMs : 0,
|
||||
fill: "forwards",
|
||||
easing: "ease-out",
|
||||
};
|
||||
panel.animate([{ transform: `translate3d(0, 0, 0)` }], timing);
|
||||
cloakElement?.animate?.([{ opacity: 1 }], timing);
|
||||
this.pxClosed = null;
|
||||
}
|
||||
|
||||
@bind
|
||||
_animateClosing(event, panel, menuOrigin) {
|
||||
this._animate = true;
|
||||
const cloakElement = document.querySelector(".header-cloak");
|
||||
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
|
||||
if (event && this.pxClosed > 0) {
|
||||
const distancePx = PANEL_WIDTH - this.pxClosed;
|
||||
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
|
||||
distancePx / Math.abs(event.velocityX)
|
||||
);
|
||||
}
|
||||
const timing = {
|
||||
duration: durationMs > 0 ? durationMs : 0,
|
||||
fill: "forwards",
|
||||
};
|
||||
|
||||
let endPosition = -PANEL_WIDTH; //origin left
|
||||
if (menuOrigin === "right") {
|
||||
endPosition = PANEL_WIDTH;
|
||||
}
|
||||
panel.animate(
|
||||
[{ transform: `translate3d(${endPosition}px, 0, 0)` }],
|
||||
timing
|
||||
);
|
||||
if (cloakElement) {
|
||||
cloakElement.animate([{ opacity: 0 }], timing);
|
||||
cloakElement.style.display = "none";
|
||||
|
||||
// to ensure that the cloak is cleared after animation we need to toggle any active menus
|
||||
if (this.header.hamburgerVisible || this.header.userVisible) {
|
||||
this.header.hamburgerVisible = false;
|
||||
this.header.userVisible = false;
|
||||
}
|
||||
}
|
||||
this.pxClosed = null;
|
||||
}
|
||||
|
||||
@bind
|
||||
onSwipeStart(event) {
|
||||
const e = event.detail;
|
||||
const center = e.center;
|
||||
const swipeOverValidElement = document
|
||||
.elementsFromPoint(center.x, center.y)
|
||||
.some(
|
||||
(ele) =>
|
||||
ele.classList.contains("panel-body") ||
|
||||
ele.classList.contains("header-cloak")
|
||||
);
|
||||
if (
|
||||
swipeOverValidElement &&
|
||||
(e.direction === "left" || e.direction === "right")
|
||||
) {
|
||||
scrollLock(true, document.querySelector(".panel-body"));
|
||||
} else {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
onSwipeEnd(event) {
|
||||
const e = event.detail;
|
||||
const menuPanels = document.querySelectorAll(".menu-panel");
|
||||
scrollLock(false, document.querySelector(".panel-body"));
|
||||
menuPanels.forEach((panel) => {
|
||||
if (this._swipeEvents.shouldCloseMenu(e, this._swipeMenuOrigin)) {
|
||||
this._animateClosing(e, panel, this._swipeMenuOrigin);
|
||||
} else {
|
||||
this._animateOpening(panel, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
onSwipeCancel() {
|
||||
const menuPanels = document.querySelectorAll(".menu-panel");
|
||||
scrollLock(false, document.querySelector(".panel-body"));
|
||||
menuPanels.forEach((panel) => {
|
||||
this._animateOpening(panel);
|
||||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
onSwipe(event) {
|
||||
const e = event.detail;
|
||||
|
||||
const movingElement = document.querySelector(".menu-panel");
|
||||
const cloakElement = document.querySelector(".header-cloak");
|
||||
|
||||
//origin left
|
||||
this.pxClosed = Math.max(0, -e.deltaX);
|
||||
let translation = -this.pxClosed;
|
||||
if (this._swipeMenuOrigin === "right") {
|
||||
this.pxClosed = Math.max(0, e.deltaX);
|
||||
translation = this.pxClosed;
|
||||
}
|
||||
|
||||
movingElement.animate(
|
||||
[{ transform: `translate3d(${translation}px, 0, 0)` }],
|
||||
{
|
||||
fill: "forwards",
|
||||
}
|
||||
);
|
||||
cloakElement?.animate?.(
|
||||
[
|
||||
{
|
||||
opacity: (PANEL_WIDTH - this.pxClosed) / PANEL_WIDTH,
|
||||
},
|
||||
],
|
||||
{ fill: "forwards" }
|
||||
);
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this.docking.destroy();
|
||||
this.appEvents.off("user-menu:rendered", this, this.animateMenu);
|
||||
|
||||
if (this.dropDownHeaderEnabled) {
|
||||
this.appEvents.off(
|
||||
"sidebar-hamburger-dropdown:rendered",
|
||||
this,
|
||||
this.animateMenu
|
||||
);
|
||||
}
|
||||
|
||||
this._itsatrap?.destroy();
|
||||
this._itsatrap = null;
|
||||
|
||||
window.removeEventListener("scroll", this._onScroll);
|
||||
this._resizeObserver?.disconnect();
|
||||
if (this.site.mobileView) {
|
||||
this._headerWrap.removeEventListener("swipestart", this.onSwipeStart);
|
||||
this._headerWrap.removeEventListener("swipeend", this.onSwipeEnd);
|
||||
this._headerWrap.removeEventListener("swipecancel", this.onSwipeCancel);
|
||||
this._headerWrap.removeEventListener("swipe", this.onSwipe);
|
||||
this._swipeEvents.removeTouchListeners();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class={{concatClass
|
||||
(unless this.site.mobileView "drop-down-mode")
|
||||
"d-header-wrap"
|
||||
}}
|
||||
{{didInsert this.setupHeader}}
|
||||
>
|
||||
<GlimmerHeader
|
||||
@canSignUp={{@canSignUp}}
|
||||
@showSidebar={{@showSidebar}}
|
||||
@sidebarEnabled={{@sidebarEnabled}}
|
||||
@toggleSidebar={{@toggleSidebar}}
|
||||
@showCreateAccount={{@showCreateAccount}}
|
||||
@showLogin={{@showLogin}}
|
||||
@animateMenu={{this.animateMenu}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
const INITIAL_DELAY_MS = 50;
|
||||
const DEBOUNCE_MS = 5;
|
||||
class Docking {
|
||||
dockCheck = null;
|
||||
_initialTimer = null;
|
||||
_queuedTimer = null;
|
||||
|
||||
constructor(dockCheck) {
|
||||
this.dockCheck = dockCheck;
|
||||
window.addEventListener("scroll", this.queueDockCheck, { passive: true });
|
||||
|
||||
// dockCheck might happen too early on full page refresh
|
||||
this._initialTimer = discourseLater(this, this.dockCheck, INITIAL_DELAY_MS);
|
||||
}
|
||||
|
||||
@debounce(DEBOUNCE_MS)
|
||||
queueDockCheck() {
|
||||
this._queuedTimer = this.dockCheck;
|
||||
}
|
||||
|
||||
@action
|
||||
destroy() {
|
||||
if (this._queuedTimer) {
|
||||
cancel(this._queuedTimer);
|
||||
}
|
||||
|
||||
cancel(this._initialTimer);
|
||||
window.removeEventListener("scroll", this.queueDockCheck);
|
||||
}
|
||||
}
|
||||
|
||||
function menuPanelContainsClass(menuPanel) {
|
||||
if (!_menuPanelClassesToForceDropdown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let className of _menuPanelClassesToForceDropdown) {
|
||||
if (menuPanel.classList.contains(className)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function forceDropdownForMenuPanels(classNames) {
|
||||
if (typeof classNames === "string") {
|
||||
classNames = [classNames];
|
||||
}
|
||||
return _menuPanelClassesToForceDropdown.push(...classNames);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { PANEL_WRAPPER_ID } from "discourse/widgets/header";
|
||||
import PanelPortal from "./glimmer-header/panel-portal";
|
||||
|
||||
export default class LegacyHeaderIconShim extends Component {
|
||||
@tracked panelElement;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
schedule("afterRender", () => {
|
||||
this.panelElement = document.querySelector(`#${PANEL_WRAPPER_ID}`);
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#let
|
||||
(component PanelPortal panelElement=this.panelElement)
|
||||
as |panelPortal|
|
||||
}}
|
||||
<@component @panelPortal={{panelPortal}} />
|
||||
{{/let}}
|
||||
</template>
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
<div
|
||||
class={{concat-class "search-menu-panel menu-panel" @animationClass}}
|
||||
class={{concat-class "menu-panel" @panelClass @animationClass}}
|
||||
data-max-width="500"
|
||||
>
|
||||
<div class="panel-body">
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
<MenuPanel @animationClass={{this.animationClass}}>
|
||||
<MenuPanel
|
||||
@animationClass={{this.animationClass}}
|
||||
@panelClass="search-menu-panel"
|
||||
>
|
||||
<SearchMenu
|
||||
@onClose={{@closeSearchMenu}}
|
||||
@inlineResults={{true}}
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
@clearSearch={{this.clearSearch}}
|
||||
/>
|
||||
{{else if this.displayMenuPanelResults}}
|
||||
<MenuPanel>
|
||||
<MenuPanel @panelClass="search-menu-panel">
|
||||
<SearchMenu::Results
|
||||
@loading={{this.loading}}
|
||||
@noResults={{this.noResults}}
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
class="btn btn-default notifications-dismiss btn-icon-text"
|
||||
title={{this.dismissTitle}}
|
||||
{{on "click" this.dismissButtonClick}}
|
||||
{{auto-focus}}
|
||||
>
|
||||
{{d-icon "check"}}
|
||||
{{i18n "user.dismiss"}}
|
||||
|
|
|
@ -2,10 +2,10 @@ import { htmlSafe } from "@ember/template";
|
|||
import { isEmpty } from "@ember/utils";
|
||||
import { avatarImg } from "discourse-common/lib/avatar-utils";
|
||||
|
||||
export default function boundAvatarTemplate(avatarTemplate, size) {
|
||||
export default function boundAvatarTemplate(avatarTemplate, size, options) {
|
||||
if (isEmpty(avatarTemplate)) {
|
||||
return htmlSafe("<div class='avatar-placeholder'></div>");
|
||||
} else {
|
||||
return htmlSafe(avatarImg({ size, avatarTemplate }));
|
||||
return htmlSafe(avatarImg({ size, avatarTemplate, ...options }));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ import {
|
|||
import { addPluginDocumentTitleCounter } from "discourse/components/d-document";
|
||||
import { addToolbarCallback } from "discourse/components/d-editor";
|
||||
import { addCategorySortCriteria } from "discourse/components/edit-category-settings";
|
||||
import { addCustomHeaderClass } from "discourse/components/glimmer-header";
|
||||
import { addToHeaderIcons as addToGlimmerHeaderIcons } from "discourse/components/glimmer-header/icons";
|
||||
import { forceDropdownForMenuPanels as glimmerForceDropdownForMenuPanels } from "discourse/components/glimmer-site-header";
|
||||
import { addGlobalNotice } from "discourse/components/global-notice";
|
||||
import { _addBulkButton } from "discourse/components/modal/topic-bulk-actions";
|
||||
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
|
||||
|
@ -142,7 +145,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api";
|
|||
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
||||
|
||||
export const PLUGIN_API_VERSION = "1.26.0";
|
||||
export const PLUGIN_API_VERSION = "1.27.0";
|
||||
|
||||
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
||||
function canModify(klass, type, resolverName, changes) {
|
||||
|
@ -931,6 +934,10 @@ class PluginApi {
|
|||
*
|
||||
**/
|
||||
addHeaderPanel(name, toggle, transformAttrs) {
|
||||
// deprecated(
|
||||
// "addHeaderPanel has been removed. Use api.addToHeaderIcons instead.",
|
||||
// { id: "discourse.add-header-panel" }
|
||||
// );
|
||||
attachAdditionalPanel(name, toggle, transformAttrs);
|
||||
}
|
||||
|
||||
|
@ -1775,17 +1782,36 @@ class PluginApi {
|
|||
addExtraIconRenderer(renderer);
|
||||
}
|
||||
/**
|
||||
* Adds a widget to the header-icon ul. The widget must already be created. You can create new widgets
|
||||
* Adds a widget or a component to the header-icon ul.
|
||||
*
|
||||
* If adding a widget it must already be created. You can create new widgets
|
||||
* in a theme or plugin via an initializer prior to calling this function.
|
||||
*
|
||||
* ```
|
||||
* api.addToHeaderIcons(
|
||||
* createWidget('some-widget')
|
||||
* createWidget("some-widget")
|
||||
* ```
|
||||
*
|
||||
* If adding a component you can pass the component directly. Additionally, you can
|
||||
* utilize the `@panelPortal` argument to create a dropdown panel. This can be useful when
|
||||
* you want create a button in the header that opens a dropdown panel with additional content.
|
||||
*
|
||||
* ```
|
||||
* api.addToHeaderIcons(
|
||||
<template>
|
||||
<span>Icon</span>
|
||||
|
||||
<@panelPortal>
|
||||
<div>Panel</div>
|
||||
</@panelPortal>
|
||||
</template>
|
||||
);
|
||||
* ```
|
||||
*
|
||||
**/
|
||||
addToHeaderIcons(icon) {
|
||||
addToHeaderIcons(icon);
|
||||
addToGlimmerHeaderIcons(icon);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1965,6 +1991,7 @@ class PluginApi {
|
|||
*/
|
||||
forceDropdownForMenuPanels(classNames) {
|
||||
forceDropdownForMenuPanels(classNames);
|
||||
glimmerForceDropdownForMenuPanels(classNames);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2775,6 +2802,19 @@ class PluginApi {
|
|||
addImageWrapperButton(label, btnClass, icon);
|
||||
addApiImageWrapperButtonClickEvent(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom css class to the header. The class or classes will live alongside the `d-header` class.
|
||||
*
|
||||
* ```
|
||||
* api.addCustomHeaderClass("class-one");
|
||||
* api.addCustomHeaderClass("class-two");
|
||||
*
|
||||
*/
|
||||
|
||||
addCustomHeaderClass(klass) {
|
||||
addCustomHeaderClass(klass);
|
||||
}
|
||||
}
|
||||
|
||||
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
|
||||
|
|
|
@ -54,6 +54,7 @@ export default function renderTopicFeaturedLink(topic) {
|
|||
return "";
|
||||
}
|
||||
}
|
||||
// deprecated per components/glimmer-header/topic/featured-link.gjs
|
||||
export function topicFeaturedLinkNode(topic) {
|
||||
const meta = extractLinkMeta(topic);
|
||||
if (meta) {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// Deprecated in favor of app/assets/javascripts/discourse/app/services/docking.js
|
||||
import Mixin from "@ember/object/mixin";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { registerDestructor } from "@ember/destroyable";
|
||||
import Modifier from "ember-modifier";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
export default class CloseOnClickOutside extends Modifier {
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
}
|
||||
|
||||
modify(element, [closeFn, { targetSelector, secondaryTargetSelector }]) {
|
||||
this.closeFn = closeFn;
|
||||
this.element = element;
|
||||
this.targetSelector = targetSelector;
|
||||
this.secondaryTargetSelector = secondaryTargetSelector;
|
||||
|
||||
document.addEventListener("pointerdown", this.check, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
check(event) {
|
||||
if (this.element.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
document.querySelector(this.targetSelector).contains(event.target) ||
|
||||
(this.secondaryTargetSelector &&
|
||||
document
|
||||
.querySelector(this.secondaryTargetSelector)
|
||||
?.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeFn(event);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
document.removeEventListener("pointerdown", this.check);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import Service from "@ember/service";
|
||||
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||
|
||||
@disableImplicitInjections
|
||||
export default class Header extends Service {
|
||||
@tracked topic = null;
|
||||
@tracked hamburgerVisible = false;
|
||||
@tracked userVisible = false;
|
||||
}
|
|
@ -9,6 +9,19 @@
|
|||
/>
|
||||
|
||||
{{#if this.showSiteHeader}}
|
||||
{{#if this.currentUser.glimmer_header_enabled}}
|
||||
<GlimmerSiteHeader
|
||||
@canSignUp={{this.canSignUp}}
|
||||
@showCreateAccount={{route-action "showCreateAccount"}}
|
||||
@showLogin={{route-action "showLogin"}}
|
||||
@showKeyboard={{route-action "showKeyboardShortcutsHelp"}}
|
||||
@toggleMobileView={{route-action "toggleMobileView"}}
|
||||
@logout={{route-action "logout"}}
|
||||
@sidebarEnabled={{this.sidebarEnabled}}
|
||||
@showSidebar={{this.showSidebar}}
|
||||
@toggleSidebar={{this.toggleSidebar}}
|
||||
/>
|
||||
{{else}}
|
||||
<SiteHeader
|
||||
@canSignUp={{this.canSignUp}}
|
||||
@showCreateAccount={{route-action "showCreateAccount"}}
|
||||
|
@ -22,6 +35,7 @@
|
|||
@toggleSidebar={{action "toggleSidebar"}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<SoftwareUpdatePrompt />
|
||||
|
||||
|
@ -36,7 +50,6 @@
|
|||
/>
|
||||
|
||||
<div id="main-outlet-wrapper" class="wrap" role="main">
|
||||
|
||||
<div class="sidebar-wrapper">
|
||||
{{! empty div allows for animation }}
|
||||
{{#if (and this.sidebarEnabled this.showSidebar)}}
|
||||
|
@ -74,7 +87,6 @@
|
|||
</div>
|
||||
|
||||
<PluginOutlet @name="after-main-outlet" />
|
||||
|
||||
</div>
|
||||
|
||||
<PluginOutlet
|
||||
|
|
|
@ -9,7 +9,9 @@ import { logSearchLinkClick } from "discourse/lib/search";
|
|||
import DiscourseURL from "discourse/lib/url";
|
||||
import { scrollTop } from "discourse/mixins/scroll-top";
|
||||
import { avatarImg } from "discourse/widgets/post";
|
||||
import RenderGlimmer from "discourse/widgets/render-glimmer";
|
||||
import RenderGlimmer, {
|
||||
registerWidgetShim,
|
||||
} from "discourse/widgets/render-glimmer";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
|
@ -18,6 +20,7 @@ import discourseLater from "discourse-common/lib/later";
|
|||
import I18n from "discourse-i18n";
|
||||
|
||||
const SEARCH_BUTTON_ID = "search-button";
|
||||
export const PANEL_WRAPPER_ID = "additional-panel-wrapper";
|
||||
|
||||
let _extraHeaderIcons = [];
|
||||
|
||||
|
@ -231,6 +234,14 @@ createWidget("header-icons", {
|
|||
services: ["search"],
|
||||
tagName: "ul.icons.d-header-icons",
|
||||
|
||||
init() {
|
||||
registerWidgetShim(
|
||||
"extra-icon",
|
||||
"div.wrapper",
|
||||
hbs`<LegacyHeaderIconShim @component={{@data.component}} />`
|
||||
);
|
||||
},
|
||||
|
||||
html(attrs) {
|
||||
if (this.siteSettings.login_required && !this.currentUser) {
|
||||
return [];
|
||||
|
@ -240,7 +251,11 @@ createWidget("header-icons", {
|
|||
|
||||
if (_extraHeaderIcons) {
|
||||
_extraHeaderIcons.forEach((icon) => {
|
||||
if (typeof icon === "string") {
|
||||
icons.push(this.attach(icon));
|
||||
} else {
|
||||
icons.push(this.attach("extra-icon", { component: icon }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -542,6 +557,8 @@ export default createWidget("header", {
|
|||
}
|
||||
});
|
||||
|
||||
panels.push(h(`div#${PANEL_WRAPPER_ID}`));
|
||||
|
||||
if (this.site.mobileView || this.site.narrowDesktopView) {
|
||||
panels.push(this.attach("header-cloak"));
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// Deprecated in favor of app/assets/javascripts/discourse/app/components/glimmer-header/sidebar-toggle.gjs
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
|
||||
export default createWidget("sidebar-toggle", {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
// deprecated in favor of app/components/glimmer-header/user-dropdown/user-status-bubble.gjs
|
||||
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
cleanUpComposerUploadPreProcessor,
|
||||
} from "discourse/components/composer-editor";
|
||||
import { clearToolbarCallbacks } from "discourse/components/d-editor";
|
||||
import { clearExtraHeaderIcons as clearExtraGlimmerHeaderIcons } from "discourse/components/glimmer-header/icons";
|
||||
import { clearBulkButtons } from "discourse/components/modal/topic-bulk-actions";
|
||||
import { resetWidgetCleanCallbacks } from "discourse/components/mount-widget";
|
||||
import { resetDecorators as resetPluginOutletDecorators } from "discourse/components/plugin-connector";
|
||||
|
@ -223,6 +224,7 @@ export function testCleanup(container, app) {
|
|||
clearToolbarCallbacks();
|
||||
resetNotificationTypeRenderers();
|
||||
resetSidebarPanels();
|
||||
clearExtraGlimmerHeaderIcons();
|
||||
clearExtraHeaderIcons();
|
||||
resetOnKeyUpCallbacks();
|
||||
resetItemSelectCallbacks();
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
// deprecated in favor of spec/system/header/site_header_spec.rb
|
||||
|
||||
import {
|
||||
click,
|
||||
render,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
// deprecated in favor of spec/system/header/header_spec.rb
|
||||
|
||||
import { click, render } from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import { module, test } from "qunit";
|
||||
|
|
|
@ -439,3 +439,13 @@ $mobile-avatar-height: 1.532em;
|
|||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
#additional-panel-wrapper {
|
||||
position: absolute;
|
||||
// positions are relative to the .d-header .panel div
|
||||
top: 100%; // directly underneath .panel
|
||||
right: -10px; // 10px to the right of .panel - adjust as needed
|
||||
max-height: 80vh;
|
||||
border-radius: var(--d-border-radius-large);
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
}
|
||||
}
|
||||
// Fade in header avatar + icons if topic title is not visible in mobile header
|
||||
.panel {
|
||||
.panel .icons {
|
||||
animation: fadein 0.5s;
|
||||
@media (prefers-reduced-motion) {
|
||||
animation-duration: 0s;
|
||||
|
|
|
@ -1816,6 +1816,10 @@ class User < ActiveRecord::Base
|
|||
in_any_groups?(SiteSetting.experimental_new_new_view_groups_map)
|
||||
end
|
||||
|
||||
def glimmer_header_enabled?
|
||||
in_any_groups?(SiteSetting.experimental_glimmer_header_groups_map)
|
||||
end
|
||||
|
||||
def watched_precedence_over_muted
|
||||
if user_option.watched_precedence_over_muted.nil?
|
||||
SiteSetting.watched_precedence_over_muted
|
||||
|
|
|
@ -74,7 +74,8 @@ class CurrentUserSerializer < BasicUserSerializer
|
|||
:new_new_view_enabled?,
|
||||
:use_experimental_topic_bulk_actions?,
|
||||
:use_experimental_topic_bulk_actions?,
|
||||
:use_admin_sidebar
|
||||
:use_admin_sidebar,
|
||||
:glimmer_header_enabled?
|
||||
|
||||
delegate :user_stat, to: :object, private: true
|
||||
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
|
||||
|
|
|
@ -2523,6 +2523,7 @@ en:
|
|||
enable_custom_sidebar_sections: "EXPERIMENTAL: Enable custom sidebar sections"
|
||||
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
|
||||
enable_experimental_lightbox: "EXPERIMENTAL: Replace the default image lightbox with the revamped design."
|
||||
experimental_glimmer_header_groups: "EXPERIMENTAL: Render the site header as glimmer components."
|
||||
|
||||
experimental_form_templates: "EXPERIMENTAL: Enable the form templates feature. <b>After enabled,</b> manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
|
||||
admin_sidebar_enabled_groups: "EXPERIMENTAL: Enable sidebar navigation for the admin UI for the specified groups, which replaces the top-level admin navigation buttons."
|
||||
|
|
|
@ -2328,6 +2328,12 @@ developer:
|
|||
instrument_gc_stat_per_request:
|
||||
default: false
|
||||
hidden: true
|
||||
experimental_glimmer_header_groups:
|
||||
client: true
|
||||
type: group_list
|
||||
list_type: compact
|
||||
default: ""
|
||||
allow_any: false
|
||||
admin_sidebar_enabled_groups:
|
||||
type: group_list
|
||||
list_type: compact
|
||||
|
|
|
@ -7,6 +7,10 @@ in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.27.0] - 2024-02-21
|
||||
|
||||
- Updated `addToHeaderIcons` to take a component instead of just a widget (masked a string). Additionally, you can can now utilize the `@panelPortal` argument to create a dropdown panel. This can be useful when * you want create a button in the header that opens a dropdown panel with additional content.
|
||||
|
||||
## [1.26.0] - 2024-02-21
|
||||
|
||||
- Added `renderBeforeWrapperOutlet` which is used for rendering components before the content of wrapper plugin outlets
|
||||
|
|
|
@ -0,0 +1,211 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe "Glimmer Header", type: :system do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
before { SiteSetting.experimental_glimmer_header_groups = Group::AUTO_GROUPS[:everyone] }
|
||||
|
||||
it "renders basics" do
|
||||
visit "/"
|
||||
expect(page).to have_css("header.d-header")
|
||||
expect(page).to have_css("#site-logo")
|
||||
end
|
||||
|
||||
it "displays sign up / login buttons" do
|
||||
visit "/"
|
||||
expect(page).to have_css("button.sign-up-button")
|
||||
expect(page).to have_css("button.login-button")
|
||||
|
||||
find("button.sign-up-button").click
|
||||
expect(page).to have_css(".d-modal.create-account")
|
||||
|
||||
click_outside
|
||||
|
||||
find("button.login-button").click
|
||||
expect(page).to have_css(".d-modal.login-modal")
|
||||
end
|
||||
|
||||
it "shows login button when login required" do
|
||||
SiteSetting.login_required = true
|
||||
|
||||
visit "/"
|
||||
expect(page).to have_css("button.login-button")
|
||||
expect(page).to have_css("button.sign-up-button")
|
||||
expect(page).not_to have_css("#search-button")
|
||||
expect(page).not_to have_css("button.btn-sidebar-toggle")
|
||||
end
|
||||
|
||||
it "renders unread notifications count when user's notifications count is updated" do
|
||||
Fabricate(
|
||||
:notification,
|
||||
user: current_user,
|
||||
high_priority: true,
|
||||
read: false,
|
||||
created_at: 8.minutes.ago,
|
||||
)
|
||||
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
expect(page).to have_selector(
|
||||
".header-dropdown-toggle.current-user .unread-notifications",
|
||||
text: "1",
|
||||
)
|
||||
end
|
||||
|
||||
it "doesn't show pending reviewables count for non-legacy navigation menu" do
|
||||
SiteSetting.navigation_menu = "sidebar"
|
||||
current_user.update!(admin: true)
|
||||
Fabricate(:reviewable)
|
||||
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
expect(page).not_to have_selector(".hamburger-dropdown .badge-notification")
|
||||
end
|
||||
|
||||
it "closes revamped menu when clicking outside" do
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
find(".header-dropdown-toggle.current-user").click
|
||||
expect(page).to have_selector(".user-menu.revamped")
|
||||
find("header.d-header").click
|
||||
expect(page).not_to have_selector(".user-menu.revamped")
|
||||
end
|
||||
|
||||
it "sets header's height css property" do
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
resize_element(".d-header", 90)
|
||||
wait_for(timeout: 100) { get_computed_style_value(".d-header", "--header-offset") == "90px" }
|
||||
expect(get_computed_style_value(".d-header", "--header-offset")).to eq("90px")
|
||||
|
||||
resize_element(".d-header", 60)
|
||||
wait_for(timeout: 100) { get_computed_style_value(".d-header", "--header-offset") == "60px" }
|
||||
expect(get_computed_style_value(".d-header", "--header-offset")).to eq("60px")
|
||||
end
|
||||
|
||||
it "moves focus between tabs using arrow keys" do
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
find(".header-dropdown-toggle.current-user").click
|
||||
expect(active_element_id).to eq("user-menu-button-all-notifications")
|
||||
|
||||
find("##{active_element_id}").send_keys(:arrow_down)
|
||||
expect(active_element_id).to eq("user-menu-button-replies")
|
||||
|
||||
4.times { find("##{active_element_id}").send_keys(:arrow_down) }
|
||||
expect(active_element_id).to eq("user-menu-button-profile")
|
||||
|
||||
find("##{active_element_id}").send_keys(:arrow_down)
|
||||
expect(active_element_id).to eq("user-menu-button-all-notifications")
|
||||
|
||||
find("##{active_element_id}").send_keys(:arrow_up)
|
||||
expect(active_element_id).to eq("user-menu-button-profile")
|
||||
end
|
||||
|
||||
it "prioritizes new personal messages bubble over unseen reviewables and regular notifications bubbles" do
|
||||
Fabricate(:private_message_notification, user: current_user)
|
||||
Fabricate(
|
||||
:notification,
|
||||
user: current_user,
|
||||
high_priority: true,
|
||||
read: false,
|
||||
created_at: 8.minutes.ago,
|
||||
)
|
||||
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
expect(page).not_to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.unread-notifications",
|
||||
)
|
||||
expect(page).not_to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-reviewables",
|
||||
)
|
||||
|
||||
expect(page).to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms",
|
||||
)
|
||||
expect(page).to have_css(".d-icon-envelope")
|
||||
expect(
|
||||
find(".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms")[:title],
|
||||
).to eq(I18n.t("js.notifications.tooltip.new_message_notification", count: 1))
|
||||
end
|
||||
|
||||
it "prioritizes unseen reviewables bubble over regular notifications" do
|
||||
current_user.update!(admin: true)
|
||||
Fabricate(:reviewable)
|
||||
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
expect(page).not_to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.unread-notifications",
|
||||
)
|
||||
expect(page).to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-reviewables",
|
||||
)
|
||||
expect(page).not_to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms",
|
||||
)
|
||||
end
|
||||
|
||||
it "shows regular notifications bubble if there are neither new personal messages nor unseen reviewables" do
|
||||
Fabricate.times(
|
||||
3,
|
||||
:notification,
|
||||
user: current_user,
|
||||
high_priority: true,
|
||||
read: false,
|
||||
created_at: 8.minutes.ago,
|
||||
)
|
||||
|
||||
sign_in(current_user)
|
||||
visit "/"
|
||||
expect(page).to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.unread-notifications",
|
||||
text: "3",
|
||||
)
|
||||
expect(
|
||||
find(".header-dropdown-toggle.current-user .badge-notification.unread-notifications")[:title],
|
||||
).to eq(I18n.t("js.notifications.tooltip.regular", count: 3))
|
||||
expect(page).not_to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-reviewables",
|
||||
)
|
||||
expect(page).not_to have_selector(
|
||||
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms",
|
||||
)
|
||||
end
|
||||
|
||||
context "when logged in and login required" do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
|
||||
it "displays current user when logged in and login required" do
|
||||
SiteSetting.login_required = true
|
||||
sign_in(current_user)
|
||||
|
||||
visit "/"
|
||||
expect(page).not_to have_css("button.login-button")
|
||||
expect(page).not_to have_css("button.sign-up-button")
|
||||
expect(page).to have_css("#search-button")
|
||||
expect(page).to have_css("button.btn-sidebar-toggle")
|
||||
expect(page).to have_css("#current-user")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_computed_style_value(selector, property)
|
||||
page.evaluate_script(
|
||||
"window.getComputedStyle(document.querySelector('#{selector}')).getPropertyValue('#{property}')",
|
||||
).strip
|
||||
end
|
||||
|
||||
def resize_element(selector, size)
|
||||
page.evaluate_script("document.querySelector('#{selector}').style.height = '#{size}px'")
|
||||
end
|
||||
|
||||
def active_element_id
|
||||
page.evaluate_script("document.activeElement.id")
|
||||
end
|
||||
|
||||
def click_outside
|
||||
find(".d-modal").click(x: 0, y: 0)
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue