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:
Isaac Janzen 2024-02-23 11:08:15 -07:00 committed by GitHub
parent d10b1aaedd
commit 21f23cc032
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2087 additions and 26 deletions

View File

@ -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;
},

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -0,0 +1,9 @@
import ConditionalInElement from "../conditional-in-element";
const PanelPortal = <template>
<ConditionalInElement @element={{@panelElement}}>
{{yield}}
</ConditionalInElement>
</template>;
export default PanelPortal;

View File

@ -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;

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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;

View File

@ -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>
}

View File

@ -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);
}

View File

@ -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>
}

View File

@ -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">

View File

@ -1,4 +1,7 @@
<MenuPanel @animationClass={{this.animationClass}}>
<MenuPanel
@animationClass={{this.animationClass}}
@panelClass="search-menu-panel"
>
<SearchMenu
@onClose={{@closeSearchMenu}}
@inlineResults={{true}}

View File

@ -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}}

View File

@ -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"}}

View File

@ -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 }));
}
}

View File

@ -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

View File

@ -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) {

View File

@ -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";

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -9,18 +9,32 @@
/>
{{#if this.showSiteHeader}}
<SiteHeader
@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}}
@navigationMenuQueryParamOverride={{this.navigationMenuQueryParamOverride}}
@showSidebar={{this.showSidebar}}
@toggleSidebar={{action "toggleSidebar"}}
/>
{{#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"}}
@showLogin={{route-action "showLogin"}}
@showKeyboard={{route-action "showKeyboardShortcutsHelp"}}
@toggleMobileView={{route-action "toggleMobileView"}}
@logout={{route-action "logout"}}
@sidebarEnabled={{this.sidebarEnabled}}
@navigationMenuQueryParamOverride={{this.navigationMenuQueryParamOverride}}
@showSidebar={{this.showSidebar}}
@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

View File

@ -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) => {
icons.push(this.attach(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"));
}

View File

@ -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", {

View File

@ -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";

View File

@ -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();

View File

@ -1,3 +1,5 @@
// deprecated in favor of spec/system/header/site_header_spec.rb
import {
click,
render,

View File

@ -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";

View File

@ -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;
}

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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."

View File

@ -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

View File

@ -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

211
spec/system/header_spec.rb Normal file
View File

@ -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