From 7d9e81b940ab388247cdcaccc6d10f80b9c15cb5 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 2 May 2023 09:42:42 +0100 Subject: [PATCH] New: Added Navigation Button API (#339) --- js/device.js | 8 +- js/models/NavigationButtonModel.js | 19 +++ js/models/NavigationModel.js | 15 ++ js/navigation.js | 24 +--- js/views/NavigationButtonView.js | 174 +++++++++++++++++++++++ js/views/adaptView.js | 4 +- js/views/navigationView.js | 219 +++++++++++++++++++++-------- less/_defaults/_variables.less | 3 + less/core/drawer.less | 12 +- less/core/nav.less | 84 ++++++++++- schema/course.model.schema | 43 ++++++ schema/course.schema.json | 41 ++++++ templates/drawer.hbs | 2 +- templates/nav.hbs | 10 +- templates/navButton.jsx | 21 +++ 15 files changed, 585 insertions(+), 94 deletions(-) create mode 100644 js/models/NavigationButtonModel.js create mode 100644 js/models/NavigationModel.js create mode 100644 js/views/NavigationButtonView.js create mode 100644 templates/navButton.jsx diff --git a/js/device.js b/js/device.js index 538c8b48..1168c2fe 100644 --- a/js/device.js +++ b/js/device.js @@ -19,7 +19,8 @@ class Device extends Backbone.Controller { this.osVersion = this.bowser.os.version || ''; this.renderingEngine = this.getRenderingEngine(); this.listenTo(Adapt, { - 'configModel:dataLoaded': this.onConfigDataLoaded + 'configModel:dataLoaded': this.onConfigDataLoaded, + 'navigationView:postRender': this.setNavigationHeight }); const browser = this.browser.toLowerCase(); // Convert 'msie' and 'internet explorer' to 'ie'. @@ -105,6 +106,10 @@ class Device extends Backbone.Controller { document.documentElement.style.setProperty('--adapt-viewport-height', `${window.innerHeight}px`); } + setNavigationHeight() { + document.documentElement.style.setProperty('--adapt-navigation-height', `${$('.nav').height()}px`); + } + getOperatingSystem() { let os = this.bowser.os.name.toLowerCase() || ''; @@ -141,6 +146,7 @@ class Device extends Backbone.Controller { this.screenWidth = this.getScreenWidth(); this.screenHeight = this.getScreenHeight(); this.setViewportHeight(); + this.setNavigationHeight(); if (previousWidth === this.screenWidth && previousHeight === this.screenHeight) { // Do not trigger a change if the viewport hasn't actually changed. Scrolling on iOS will trigger a resize. diff --git a/js/models/NavigationButtonModel.js b/js/models/NavigationButtonModel.js new file mode 100644 index 00000000..61362c0a --- /dev/null +++ b/js/models/NavigationButtonModel.js @@ -0,0 +1,19 @@ +import LockingModel from 'core/js/models/lockingModel'; + +export default class NavigationButtonModel extends LockingModel { + + defaults() { + return { + _id: '', + _classes: '', + _iconClasses: '', + _order: 0, + _event: '', + _showLabel: null, + _role: 'button', + ariaLabel: '', + text: '{{ariaLabel}}' + }; + } + +} diff --git a/js/models/NavigationModel.js b/js/models/NavigationModel.js new file mode 100644 index 00000000..9913b211 --- /dev/null +++ b/js/models/NavigationModel.js @@ -0,0 +1,15 @@ +import LockingModel from 'core/js/models/lockingModel'; + +export default class NavigationModel extends LockingModel { + + defaults() { + return { + _navigationAlignment: 'top', + _isBottomOnTouchDevices: false, + _showLabel: false, + _showLabelAtWidth: 'medium', + _labelPosition: 'auto' + }; + } + +} 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..a0d8cce8 --- /dev/null +++ b/js/views/NavigationButtonView.js @@ -0,0 +1,174 @@ +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 { + + tagName() { + return 'button'; + } + + events() { + return { + click: 'triggerEvent' + }; + } + + className() { + if (this.isInjectedButton) { + return [ + this.model.get('_showLabel') === false && 'hide-label' + ].filter(Boolean).join(' '); + } + return [ + 'btn-icon nav__btn', + this.model.get('_classes'), + this.model.get('_showLabel') === false && 'hide-label' + ].filter(Boolean).join(' '); + } + + attributes() { + const attributes = this.model.toJSON(); + if (this.isInjectedButton) { + return { + name: attributes._id, + 'data-order': attributes._order, + 'data-event': attributes._event + }; + } + return { + name: attributes._id, + role: attributes._role === 'button' ? undefined : attributes._role, + 'aria-label': attributes.ariaLabel, + '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 get template() { + return 'navButton.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('> .nav__btn-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 + * @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(