Skip to content

Commit

Permalink
New: Added Navigation Button API (#339)
Browse files Browse the repository at this point in the history
  • Loading branch information
oliverfoster committed May 4, 2023
1 parent c8278c1 commit 7d9e81b
Show file tree
Hide file tree
Showing 15 changed files with 585 additions and 94 deletions.
8 changes: 7 additions & 1 deletion js/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand Down Expand Up @@ -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() || '';

Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions js/models/NavigationButtonModel.js
Original file line number Diff line number Diff line change
@@ -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}}'
};
}

}
15 changes: 15 additions & 0 deletions js/models/NavigationModel.js
Original file line number Diff line number Diff line change
@@ -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'
};
}

}
24 changes: 5 additions & 19 deletions js/navigation.js
Original file line number Diff line number Diff line change
@@ -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);
174 changes: 174 additions & 0 deletions js/views/NavigationButtonView.js
Original file line number Diff line number Diff line change
@@ -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(`<span class="nav__btn-label" aria-hidden="true">${output}</span>`);
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(<Template {...props} />, this.el);
}

triggerEvent(event) {
event.preventDefault();
const currentEvent = $(event.currentTarget).attr('data-event');
if (!currentEvent) return;
Adapt.trigger('navigation:' + currentEvent);
switch (currentEvent) {
case 'backButton':
router.navigateToPreviousRoute();
break;
case 'homeButton':
router.navigateToHomeRoute();
break;
case 'parentButton':
router.navigateToParent();
break;
case 'skipNavigation':
a11y.focusFirst('.' + location._contentType);
break;
case 'returnToStart':
startController.returnToStartLocation();
break;
}
}

remove() {
this._isRemoved = true;
this.stopListening();
wait.for(end => {
if (this.isJSX) {
ReactDOM.unmountComponentAtNode(this.el);
}
super.remove();
end();
});
return this;
}

}
4 changes: 2 additions & 2 deletions js/views/adaptView.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ class AdaptView extends Backbone.View {
this.isJSX = (this.constructor.template || '').includes('.jsx');
if (this.isJSX) {
this._classSet = new Set(_.result(this, 'className').trim().split(/\s+/));
this.listenTo(this.model, 'all', this.changed);
this.listenTo(this.model, 'change', this.changed);
const children = this.model?.getChildren?.();
children && this.listenTo(children, 'all', this.changed);
children && this.listenTo(children, 'change', this.changed);
// Facilitate adaptive react views
this.listenTo(Adapt, 'device:changed', this.changed);
}
Expand Down
Loading

0 comments on commit 7d9e81b

Please sign in to comment.