mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Introduce 'loading slider' for page navigations (#22042)
This brings the functionality from https://github.com/discourse/discourse-loading-slider into Discourse core. Default behaviour remains the same - the new slider mode can be enabled using the new 'page_loading_indicator' site setting.
This commit is contained in:
parent
a9dfda2d66
commit
d51baa3bb3
@ -0,0 +1,3 @@
|
|||||||
|
{{#if this.loadingSlider.stillLoading}}
|
||||||
|
<div class="loading-slider-fallback-spinner">{{loading-spinner}}</div>
|
||||||
|
{{/if}}
|
@ -0,0 +1,6 @@
|
|||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
|
||||||
|
export default class LoadingSliderFallbackSpinner extends Component {
|
||||||
|
@service loadingSlider;
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
{{#if this.loadingSlider.enabled}}
|
||||||
|
<div
|
||||||
|
class={{concat-class
|
||||||
|
"loading-indicator-container"
|
||||||
|
this.state
|
||||||
|
(if this.capabilities.isAppWebview "discourse-hub-webview")
|
||||||
|
}}
|
||||||
|
{{on "transitionend" this.onContainerTransitionEnd}}
|
||||||
|
style={{this.containerStyle}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="loading-indicator"
|
||||||
|
{{on "transitionend" this.onBarTransitionEnd}}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
@ -0,0 +1,67 @@
|
|||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import { cancel, next } from "@ember/runloop";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
import { htmlSafe } from "@ember/template";
|
||||||
|
|
||||||
|
export default class extends Component {
|
||||||
|
@service loadingSlider;
|
||||||
|
@service capabilities;
|
||||||
|
|
||||||
|
@tracked state = "ready";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.loadingSlider.on("stateChanged", this.stateChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
stateChanged(loading) {
|
||||||
|
if (this._deferredStateChange) {
|
||||||
|
cancel(this._deferredStateChange);
|
||||||
|
this._deferredStateChange = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && this.ready) {
|
||||||
|
this.state = "loading";
|
||||||
|
} else if (loading) {
|
||||||
|
this.state = "ready";
|
||||||
|
this._deferredStateChange = next(() => (this.state = "loading"));
|
||||||
|
} else {
|
||||||
|
this.state = "done";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.loadingSlider.off("stateChange", this, "stateChange");
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onContainerTransitionEnd(event) {
|
||||||
|
if (
|
||||||
|
event.target === event.currentTarget &&
|
||||||
|
event.propertyName === "opacity"
|
||||||
|
) {
|
||||||
|
this.state = "ready";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onBarTransitionEnd(event) {
|
||||||
|
if (
|
||||||
|
event.target === event.currentTarget &&
|
||||||
|
event.propertyName === "transform" &&
|
||||||
|
this.state === "loading"
|
||||||
|
) {
|
||||||
|
this.state = "still-loading";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get containerStyle() {
|
||||||
|
const duration = this.loadingSlider.averageLoadingDuration.toFixed(2);
|
||||||
|
return htmlSafe(`--loading-duration: ${duration}s`);
|
||||||
|
}
|
||||||
|
}
|
@ -309,6 +309,7 @@ export default MountWidget.extend({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.queueRerender();
|
this.queueRerender();
|
||||||
|
this._scrollTriggered();
|
||||||
},
|
},
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
@ -370,6 +371,10 @@ export default MountWidget.extend({
|
|||||||
this.appEvents.off("post-stream:posted", this, "_posted");
|
this.appEvents.off("post-stream:posted", this, "_posted");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
didUpdateAttrs() {
|
||||||
|
this._refresh();
|
||||||
|
},
|
||||||
|
|
||||||
_handleWidgetButtonHoverState(event) {
|
_handleWidgetButtonHoverState(event) {
|
||||||
if (event.target.classList.contains("widget-button")) {
|
if (event.target.classList.contains("widget-button")) {
|
||||||
document
|
document
|
||||||
|
@ -34,7 +34,11 @@ export function showEntrance(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToTopic(topic, href) {
|
export function navigateToTopic(topic, href) {
|
||||||
this.appEvents.trigger("header:update-topic", topic);
|
if (this.siteSettings.page_loading_indicator !== "slider") {
|
||||||
|
// With the slider, it feels nicer for the header to update once the rest of the topic content loads,
|
||||||
|
// so skip setting it early.
|
||||||
|
this.appEvents.trigger("header:update-topic", topic);
|
||||||
|
}
|
||||||
DiscourseURL.routeTo(href || topic.get("url"));
|
DiscourseURL.routeTo(href || topic.get("url"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -60,6 +60,13 @@ export default Controller.extend({
|
|||||||
return `${url}?${urlSearchParams.toString()}`;
|
return `${url}?${urlSearchParams.toString()}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get showLoadingSpinner() {
|
||||||
|
return (
|
||||||
|
this.get("loading") &&
|
||||||
|
this.siteSettings.page_loading_indicator === "spinner"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
changePeriod(p) {
|
changePeriod(p) {
|
||||||
DiscourseURL.routeTo(this.showMoreUrl(p));
|
DiscourseURL.routeTo(this.showMoreUrl(p));
|
||||||
|
@ -4,7 +4,6 @@ import DismissTopics from "discourse/mixins/dismiss-topics";
|
|||||||
import DiscoveryController from "discourse/controllers/discovery";
|
import DiscoveryController from "discourse/controllers/discovery";
|
||||||
import I18n from "I18n";
|
import I18n from "I18n";
|
||||||
import Topic from "discourse/models/topic";
|
import Topic from "discourse/models/topic";
|
||||||
import TopicList from "discourse/models/topic-list";
|
|
||||||
import { inject as controller } from "@ember/controller";
|
import { inject as controller } from "@ember/controller";
|
||||||
import deprecated from "discourse-common/lib/deprecated";
|
import deprecated from "discourse-common/lib/deprecated";
|
||||||
import discourseComputed from "discourse-common/utils/decorators";
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
@ -106,41 +105,11 @@ const controllerOpts = {
|
|||||||
);
|
);
|
||||||
return routeAction("changeSort", this.router._router, ...arguments)();
|
return routeAction("changeSort", this.router._router, ...arguments)();
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
|
||||||
refresh(options = { skipResettingParams: [] }) {
|
@action
|
||||||
const filter = this.get("model.filter");
|
refresh() {
|
||||||
this.send("resetParams", options.skipResettingParams);
|
this.send("triggerRefresh");
|
||||||
|
|
||||||
// Don't refresh if we're still loading
|
|
||||||
if (this.discovery.loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we `send('loading')` here, due to returning true it bubbles up to the
|
|
||||||
// router and ember throws an error due to missing `handlerInfos`.
|
|
||||||
// Lesson learned: Don't call `loading` yourself.
|
|
||||||
this.discovery.loadingBegan();
|
|
||||||
|
|
||||||
this.topicTrackingState.resetTracking();
|
|
||||||
|
|
||||||
this.store.findFiltered("topicList", { filter }).then((list) => {
|
|
||||||
TopicList.hideUniformCategory(list, this.category);
|
|
||||||
|
|
||||||
// If query params are present in the current route, we need still need to sync topic
|
|
||||||
// tracking with the topicList without any query params. Then we set the topic
|
|
||||||
// list to the list filtered with query params in the afterRefresh.
|
|
||||||
const params = this.router.currentRoute.queryParams;
|
|
||||||
if (Object.keys(params).length) {
|
|
||||||
this.store
|
|
||||||
.findFiltered("topicList", { filter, params })
|
|
||||||
.then((listWithParams) => {
|
|
||||||
this.afterRefresh(filter, list, listWithParams);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.afterRefresh(filter, list);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
afterRefresh(filter, list, listModel = list) {
|
afterRefresh(filter, list, listModel = list) {
|
||||||
|
@ -14,6 +14,7 @@ import { inject as service } from "@ember/service";
|
|||||||
import { setting } from "discourse/lib/computed";
|
import { setting } from "discourse/lib/computed";
|
||||||
import showModal from "discourse/lib/show-modal";
|
import showModal from "discourse/lib/show-modal";
|
||||||
import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help";
|
import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
|
||||||
function unlessReadOnly(method, message) {
|
function unlessReadOnly(method, message) {
|
||||||
return function () {
|
return function () {
|
||||||
@ -42,6 +43,20 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
|
|||||||
dialog: service(),
|
dialog: service(),
|
||||||
composer: service(),
|
composer: service(),
|
||||||
modal: service(),
|
modal: service(),
|
||||||
|
loadingSlider: service(),
|
||||||
|
|
||||||
|
@action
|
||||||
|
loading(transition) {
|
||||||
|
if (this.loadingSlider.enabled) {
|
||||||
|
this.loadingSlider.transitionStarted();
|
||||||
|
transition.promise.finally(() => {
|
||||||
|
this.loadingSlider.transitionEnded();
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true; // Use native ember loading implementation
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
toggleAnonymous() {
|
toggleAnonymous() {
|
||||||
|
@ -98,4 +98,9 @@ export default DiscourseRoute.extend(OpenComposer, {
|
|||||||
includeSubcategories: !controller.noSubcategories,
|
includeSubcategories: !controller.noSubcategories,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
triggerRefresh() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
129
app/assets/javascripts/discourse/app/services/loading-slider.js
Normal file
129
app/assets/javascripts/discourse/app/services/loading-slider.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import Service, { inject as service } from "@ember/service";
|
||||||
|
import Evented from "@ember/object/evented";
|
||||||
|
import { cancel, later, schedule } from "@ember/runloop";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||||
|
|
||||||
|
const STORE_LOADING_TIMES = 5;
|
||||||
|
const DEFAULT_LOADING_TIME = 0.3;
|
||||||
|
const MIN_LOADING_TIME = 0.1;
|
||||||
|
|
||||||
|
const STILL_LOADING_DURATION = 2;
|
||||||
|
|
||||||
|
class RollingAverage {
|
||||||
|
@tracked average;
|
||||||
|
#values = [];
|
||||||
|
#i = 0;
|
||||||
|
#size;
|
||||||
|
|
||||||
|
constructor(size, initialAverage) {
|
||||||
|
this.#size = size;
|
||||||
|
this.average = initialAverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
record(value) {
|
||||||
|
this.#values[this.#i] = value;
|
||||||
|
this.#i = (this.#i + 1) % this.#size;
|
||||||
|
this.average =
|
||||||
|
this.#values.reduce((p, c) => p + c, 0) / this.#values.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScheduleManager {
|
||||||
|
#scheduled = [];
|
||||||
|
|
||||||
|
cancelAll() {
|
||||||
|
this.#scheduled.forEach((s) => cancel(s));
|
||||||
|
this.#scheduled = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule() {
|
||||||
|
this.#scheduled.push(schedule(...arguments));
|
||||||
|
}
|
||||||
|
|
||||||
|
later() {
|
||||||
|
this.#scheduled.push(later(...arguments));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Timer {
|
||||||
|
#startedAt;
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.#startedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
return (Date.now() - this.#startedAt) / 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@disableImplicitInjections
|
||||||
|
export default class LoadingSlider extends Service.extend(Evented) {
|
||||||
|
@service siteSettings;
|
||||||
|
@tracked loading = false;
|
||||||
|
@tracked stillLoading = false;
|
||||||
|
|
||||||
|
rollingAverage = new RollingAverage(
|
||||||
|
STORE_LOADING_TIMES,
|
||||||
|
DEFAULT_LOADING_TIME
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleManager = new ScheduleManager();
|
||||||
|
|
||||||
|
timer = new Timer();
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return this.siteSettings.page_loading_indicator === "slider";
|
||||||
|
}
|
||||||
|
|
||||||
|
get averageLoadingDuration() {
|
||||||
|
return this.rollingAverage.average;
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionStarted() {
|
||||||
|
this.timer.start();
|
||||||
|
this.loading = true;
|
||||||
|
this.trigger("stateChanged", true);
|
||||||
|
|
||||||
|
this.scheduleManager.cancelAll();
|
||||||
|
|
||||||
|
this.scheduleManager.later(
|
||||||
|
this.setStillLoading,
|
||||||
|
STILL_LOADING_DURATION * 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
transitionEnded() {
|
||||||
|
let duration = this.timer.stop();
|
||||||
|
if (duration < MIN_LOADING_TIME) {
|
||||||
|
duration = MIN_LOADING_TIME;
|
||||||
|
}
|
||||||
|
this.rollingAverage.record(duration);
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.stillLoading = false;
|
||||||
|
this.trigger("stateChanged", false);
|
||||||
|
|
||||||
|
this.scheduleManager.cancelAll();
|
||||||
|
this.scheduleManager.schedule("afterRender", this.removeClasses);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
setStillLoading() {
|
||||||
|
this.stillLoading = true;
|
||||||
|
this.scheduleManager.schedule("afterRender", this.addStillLoadingClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
addStillLoadingClass() {
|
||||||
|
document.body.classList.add("still-loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
removeClasses() {
|
||||||
|
document.body.classList.remove("loading", "still-loading");
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
<DiscourseRoot>
|
<DiscourseRoot>
|
||||||
<a href="#main-container" id="skip-link">{{i18n "skip_to_main_content"}}</a>
|
<a href="#main-container" id="skip-link">{{i18n "skip_to_main_content"}}</a>
|
||||||
<DDocument />
|
<DDocument />
|
||||||
|
<PageLoadingSlider />
|
||||||
<PluginOutlet
|
<PluginOutlet
|
||||||
@name="above-site-header"
|
@name="above-site-header"
|
||||||
@connectorTagName="div"
|
@connectorTagName="div"
|
||||||
@ -44,6 +45,8 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LoadingSliderFallbackSpinner />
|
||||||
|
|
||||||
<PluginOutlet @name="before-main-outlet" />
|
<PluginOutlet @name="before-main-outlet" />
|
||||||
|
|
||||||
<div id="main-outlet">
|
<div id="main-outlet">
|
||||||
|
@ -22,13 +22,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConditionalLoadingSpinner @condition={{this.loading}} />
|
<ConditionalLoadingSpinner @condition={{this.showLoadingSpinner}} />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<PluginOutlet @name="discovery-above" @connectorTagName="div" />
|
<PluginOutlet @name="discovery-above" @connectorTagName="div" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="container list-container {{if this.loading 'hidden'}}">
|
<div class="container list-container {{if this.showLoadingSpinner 'hidden'}}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="full-width">
|
<div class="full-width">
|
||||||
<div id="header-list-area">
|
<div id="header-list-area">
|
||||||
|
@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
currentRouteName,
|
||||||
|
getSettledState,
|
||||||
|
settled,
|
||||||
|
visit,
|
||||||
|
waitUntil,
|
||||||
|
} from "@ember/test-helpers";
|
||||||
|
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
|
||||||
|
import { test } from "qunit";
|
||||||
|
import pretender from "discourse/tests/helpers/create-pretender";
|
||||||
|
import AboutFixtures from "discourse/tests/fixtures/about";
|
||||||
|
|
||||||
|
// Like settled(), but ignores timers, transitions and network requests
|
||||||
|
function isMostlySettled() {
|
||||||
|
let { hasRunLoop, hasPendingWaiters, isRenderPending } = getSettledState();
|
||||||
|
|
||||||
|
if (hasRunLoop || hasPendingWaiters || isRenderPending) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mostlySettled() {
|
||||||
|
return waitUntil(isMostlySettled);
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptance("Page Loading Indicator", function (needs) {
|
||||||
|
let pendingRequest;
|
||||||
|
let resolvePendingRequest;
|
||||||
|
|
||||||
|
needs.pretender((server, helper) => {
|
||||||
|
pendingRequest = new Promise(
|
||||||
|
(resolve) => (resolvePendingRequest = resolve)
|
||||||
|
);
|
||||||
|
|
||||||
|
pretender.get(
|
||||||
|
"/about.json",
|
||||||
|
(request) => {
|
||||||
|
resolvePendingRequest(request);
|
||||||
|
return helper.response(AboutFixtures["about.json"]);
|
||||||
|
},
|
||||||
|
true // Require manual resolution
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it works in 'spinner' mode", async function (assert) {
|
||||||
|
this.siteSettings.page_loading_indicator = "spinner";
|
||||||
|
|
||||||
|
await visit("/");
|
||||||
|
visit("/about");
|
||||||
|
|
||||||
|
const aboutRequest = await pendingRequest;
|
||||||
|
await mostlySettled();
|
||||||
|
|
||||||
|
assert.strictEqual(currentRouteName(), "about_loading");
|
||||||
|
assert.dom("#main-outlet > div.spinner").exists();
|
||||||
|
assert.dom(".loading-indicator-container").doesNotExist();
|
||||||
|
|
||||||
|
pretender.resolve(aboutRequest);
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.strictEqual(currentRouteName(), "about");
|
||||||
|
assert.dom("#main-outlet > div.spinner").doesNotExist();
|
||||||
|
assert.dom("#main-outlet section.about").exists();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it works in 'slider' mode", async function (assert) {
|
||||||
|
this.siteSettings.page_loading_indicator = "slider";
|
||||||
|
|
||||||
|
await visit("/");
|
||||||
|
|
||||||
|
assert.dom(".loading-indicator-container").exists();
|
||||||
|
assert.dom(".loading-indicator-container").hasClass("ready");
|
||||||
|
|
||||||
|
visit("/about");
|
||||||
|
|
||||||
|
const aboutRequest = await pendingRequest;
|
||||||
|
await mostlySettled();
|
||||||
|
|
||||||
|
assert.strictEqual(currentRouteName(), "discovery.latest");
|
||||||
|
assert.dom("#main-outlet > div.spinner").doesNotExist();
|
||||||
|
|
||||||
|
await waitUntil(() =>
|
||||||
|
query(".loading-indicator-container").classList.contains("loading")
|
||||||
|
);
|
||||||
|
|
||||||
|
pretender.resolve(aboutRequest);
|
||||||
|
|
||||||
|
await waitUntil(() =>
|
||||||
|
query(".loading-indicator-container").classList.contains("done")
|
||||||
|
);
|
||||||
|
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.strictEqual(currentRouteName(), "about");
|
||||||
|
assert.dom("#main-outlet section.about").exists();
|
||||||
|
});
|
||||||
|
});
|
@ -17,3 +17,4 @@
|
|||||||
@import "common/d-editor";
|
@import "common/d-editor";
|
||||||
@import "common/software-update-prompt";
|
@import "common/software-update-prompt";
|
||||||
@import "common/topic-timeline";
|
@import "common/topic-timeline";
|
||||||
|
@import "common/loading-slider";
|
||||||
|
74
app/assets/stylesheets/common/loading-slider.scss
Normal file
74
app/assets/stylesheets/common/loading-slider.scss
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
.loading-indicator-container {
|
||||||
|
--loading-width: 0.8;
|
||||||
|
--still-loading-width: 0.9;
|
||||||
|
|
||||||
|
--still-loading-duration: 10s;
|
||||||
|
--done-duration: 0.4s;
|
||||||
|
--fade-out-duration: 0.4s;
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: z("header") + 1;
|
||||||
|
|
||||||
|
height: 3px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--fade-out-duration) ease var(--done-duration);
|
||||||
|
|
||||||
|
background-color: var(--primary-low);
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
background-color: var(--tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading,
|
||||||
|
&.still-loading {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading .loading-indicator {
|
||||||
|
transition: transform var(--loading-duration) ease-in;
|
||||||
|
transform: scaleX(var(--loading-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.still-loading .loading-indicator {
|
||||||
|
transition: transform var(--still-loading-duration) linear;
|
||||||
|
transform: scaleX(var(--still-loading-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.done .loading-indicator {
|
||||||
|
transition: transform var(--done-duration) ease-out;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.discourse-hub-webview {
|
||||||
|
// DiscourseHub obscures the top 1px to work around an iOS bug
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.footer-nav-ipad & {
|
||||||
|
top: var(--footer-nav-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-slider-fallback-spinner {
|
||||||
|
padding-top: 1.8em;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.still-loading {
|
||||||
|
.loading-slider-fallback-spinner {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-outlet {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
@ -2435,6 +2435,8 @@ en:
|
|||||||
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
|
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
|
||||||
experimental_search_menu_groups: "EXPERIMENTAL: Enables the new search menu that has been upgraded to use glimmer"
|
experimental_search_menu_groups: "EXPERIMENTAL: Enables the new search menu that has been upgraded to use glimmer"
|
||||||
|
|
||||||
|
page_loading_indicator: "Configure the loading indicator which appears during page navigations within Discourse. 'Spinner' is a full page indicator. 'Slider' shows a narrow bar at the top of the screen."
|
||||||
|
|
||||||
errors:
|
errors:
|
||||||
invalid_css_color: "Invalid color. Enter a color name or hex value."
|
invalid_css_color: "Invalid color. Enter a color name or hex value."
|
||||||
invalid_email: "Invalid email address."
|
invalid_email: "Invalid email address."
|
||||||
|
@ -393,6 +393,13 @@ basic:
|
|||||||
client: true
|
client: true
|
||||||
default: true
|
default: true
|
||||||
refresh: true
|
refresh: true
|
||||||
|
page_loading_indicator:
|
||||||
|
client: true
|
||||||
|
type: enum
|
||||||
|
default: "spinner"
|
||||||
|
choices:
|
||||||
|
- spinner
|
||||||
|
- slider
|
||||||
|
|
||||||
login:
|
login:
|
||||||
invite_only:
|
invite_only:
|
||||||
|
Loading…
Reference in New Issue
Block a user