From d9dfbab56bd0c90adacd395bff4b5466c940fcf6 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 30 Mar 2023 16:02:37 +0100 Subject: [PATCH 01/24] New: Added Navigation Button API --- js/models/NavigationButtonModel.js | 14 ++ js/models/NavigationModel.js | 13 ++ js/navigation.js | 24 +--- js/views/NavigationButtonView.js | 151 +++++++++++++++++++++ js/views/adaptView.js | 6 +- js/views/navigationView.js | 205 +++++++++++++++++++++-------- less/core/nav.less | 9 ++ templates/nav.hbs | 10 +- 8 files changed, 353 insertions(+), 79 deletions(-) create mode 100644 js/models/NavigationButtonModel.js create mode 100644 js/models/NavigationModel.js create mode 100644 js/views/NavigationButtonView.js diff --git a/js/models/NavigationButtonModel.js b/js/models/NavigationButtonModel.js new file mode 100644 index 00000000..1a4afe1a --- /dev/null +++ b/js/models/NavigationButtonModel.js @@ -0,0 +1,14 @@ +import LockingModel from 'core/js/models/lockingModel'; + +export default class NavigationButtonModel extends LockingModel { + + defaults() { + return { + _id: '', + _order: 0, + _event: '', + text: '{{ariaLabel}}' + }; + } + +} diff --git a/js/models/NavigationModel.js b/js/models/NavigationModel.js new file mode 100644 index 00000000..d23fbe65 --- /dev/null +++ b/js/models/NavigationModel.js @@ -0,0 +1,13 @@ +import LockingModel from 'core/js/models/lockingModel'; + +export default class NavigationModel extends LockingModel { + + defaults() { + return { + _navigationAlignment: 'top', + _isBottomOnTouchDevices: false, + _showLabel: false + }; + } + +} diff --git a/js/navigation.js b/js/navigation.js index 56e9c6d4..b655c356 100644 --- a/js/navigation.js +++ b/js/navigation.js @@ -1,37 +1,23 @@ import Adapt from 'core/js/adapt'; import NavigationView from 'core/js/views/navigationView'; -import device from './device'; +import NavigationModel from './models/NavigationModel'; class NavigationController extends Backbone.Controller { initialize() { - this.listenTo(Adapt, { - 'adapt:preInitialize': this.addNavigationBar, - 'adapt:preInitialize device:resize': this.onDeviceResize - }); + this.navigation = new NavigationView(); + this.listenTo(Adapt, 'adapt:preInitialize', this.addNavigationBar); } addNavigationBar() { const adaptConfig = Adapt.course.get('_navigation'); - if (adaptConfig?._isDefaultNavigationDisabled) { Adapt.trigger('navigation:initialize'); return; } - - Adapt.navigation = new NavigationView();// This should be triggered after 'app:dataReady' as plugins might want to manipulate the navigation - } - - onDeviceResize() { - const adaptConfig = Adapt.course.get('_navigation'); - const $html = $('html'); - $html.addClass('is-nav-top'); - let navigationAlignment = adaptConfig?._navigationAlignment ?? 'top'; - const isBottomOnTouchDevices = (device.touch && adaptConfig?._isBottomOnTouchDevices); - if (isBottomOnTouchDevices) navigationAlignment = 'bottom'; - $html.removeClass('is-nav-top').addClass('is-nav-' + navigationAlignment); + this.navigation.start(new NavigationModel(adaptConfig)); } } -export default new NavigationController(); +export default (Adapt.navigation = (new NavigationController()).navigation); diff --git a/js/views/NavigationButtonView.js b/js/views/NavigationButtonView.js new file mode 100644 index 00000000..194e92ff --- /dev/null +++ b/js/views/NavigationButtonView.js @@ -0,0 +1,151 @@ +import Adapt from 'core/js/adapt'; +import wait from 'core/js/wait'; +import { compile, templates } from 'core/js/reactHelpers'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import router from 'core/js/router'; +import startController from 'core/js/startController'; +import a11y from 'core/js/a11y'; +import location from 'core/js/location'; + +export default class NavigationButtonView extends Backbone.View { + + events() { + return { + click: 'triggerEvent' + }; + } + + className() { + return ''; + } + + attributes() { + const attributes = this.model.toJSON(); + return { + 'data-order': attributes._order, + 'data-event': attributes._event + }; + } + + initialize({ el }) { + if (el) { + this.isInjectedButton = true; + } else { + this.isJSX = (this.constructor.template || '').includes('.jsx'); + } + this._classSet = new Set(_.result(this, 'className').trim().split(/\s+/)); + this._attributes = _.result(this, 'attributes'); + this.listenTo(this.model, 'change', this.changed); + this.render(); + } + + static template() { + return 'navigation-button.jsx'; + } + + render() { + if (this.isInjectedButton) { + this.changed(); + } else if (this.isJSX) { + this.changed(); + } else { + const data = this.model.toJSON(); + data.view = this; + const template = Handlebars.templates[this.constructor.template]; + this.$el.html(template(data)); + } + return this; + } + + updateViewProperties() { + const classesToAdd = _.result(this, 'className').trim().split(/\s+/); + classesToAdd.forEach(i => this._classSet.add(i)); + const classesToRemove = [ ...this._classSet ].filter(i => !classesToAdd.includes(i)); + classesToRemove.forEach(i => this._classSet.delete(i)); + Object.keys(this._attributes).forEach(name => this.$el.removeAttr(name)); + Object.entries(_.result(this, 'attributes')).forEach(([name, value]) => this.$el.attr(name, value)); + this.$el.removeClass(classesToRemove).addClass(classesToAdd); + } + + injectLabel() { + const textLabel = this.$el.find('> .label'); + const ariaLabel = this.$el.attr('aria-label') ?? this.$el.find('.aria-label').text(); + const text = this.model.get('text'); + const output = compile(text ?? '', { ariaLabel }); + if (!textLabel.length) { + this.$el.append(``); + return; + } + textLabel.html(output); + } + + /** + * Re-render a react template + * @param {string} eventName=null Backbone change event name + */ + changed(eventName = null) { + if (typeof eventName === 'string' && eventName.startsWith('bubble')) { + // Ignore bubbling events as they are outside of this view's scope + return; + } + if (this.isInjectedButton) { + this.updateViewProperties(); + this.injectLabel(); + return; + } + if (!this.isJSX) { + this.updateViewProperties(); + return; + } + const props = { + // Add view own properties, bound functions etc + ...this, + // Add model json data + ...this.model.toJSON(), + // Add globals + _globals: Adapt.course.get('_globals') + }; + const Template = templates[this.constructor.template.replace('.jsx', '')]; + this.updateViewProperties(); + ReactDOM.render(