From 924c874d745627e82ea541d8c9d680af93d161fd Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Thu, 14 Dec 2017 17:07:30 -0800 Subject: [PATCH] [IMP] web_responsive: Add search feature (#838) * Add feature to search menus in the app drawer * Switch model to RPC --- web_responsive/__manifest__.py | 3 +- web_responsive/readme/DESCRIPTION.rst | 23 +- web_responsive/readme/ROADMAP.rst | 7 + .../{drawer.3.2.0.css => drawer.3.2.2.css} | 43 +- .../static/lib/js/bililiteRange.2.6.js | 765 ++++++++++++++++++ .../js/{drawer.3.2.0.js => drawer.3.2.2.js} | 2 +- .../static/lib/js/jquery.sendkeys.4.js | 57 ++ .../static/src/js/web_responsive.js | 294 +++++-- .../static/src/less/app_drawer.less | 14 + .../static/src/xml/app_drawer_menu_search.xml | 16 + web_responsive/views/assets.xml | 10 +- web_responsive/views/web.xml | 69 +- 12 files changed, 1207 insertions(+), 96 deletions(-) rename web_responsive/static/lib/css/{drawer.3.2.0.css => drawer.3.2.2.css} (96%) create mode 100644 web_responsive/static/lib/js/bililiteRange.2.6.js rename web_responsive/static/lib/js/{drawer.3.2.0.js => drawer.3.2.2.js} (99%) create mode 100644 web_responsive/static/lib/js/jquery.sendkeys.4.js create mode 100644 web_responsive/static/src/xml/app_drawer_menu_search.xml diff --git a/web_responsive/__manifest__.py b/web_responsive/__manifest__.py index 4d11ac92..88891fcd 100644 --- a/web_responsive/__manifest__.py +++ b/web_responsive/__manifest__.py @@ -6,7 +6,7 @@ "name": "Web Responsive", "summary": "It provides a mobile compliant interface for Odoo Community " "web", - "version": "11.0.1.0.2", + "version": "11.0.2.0.0", "category": "Website", "website": "https://laslabs.com/", "author": "LasLabs, Tecnativa, Alexandre Díaz, " @@ -22,6 +22,7 @@ 'views/inherited_view_users_form_simple_modif.xml', ], 'qweb': [ + 'static/src/xml/app_drawer_menu_search.xml', 'static/src/xml/form_view.xml', 'static/src/xml/navbar.xml', ], diff --git a/web_responsive/readme/DESCRIPTION.rst b/web_responsive/readme/DESCRIPTION.rst index 74965400..ad22d7cd 100644 --- a/web_responsive/readme/DESCRIPTION.rst +++ b/web_responsive/readme/DESCRIPTION.rst @@ -2,7 +2,22 @@ This module provides a mobile compliant interface for Odoo Community web. Features: - * New navigation with an App drawer - * Keyboard shortcuts for easier navigation - * Display kanban views for small screens if an action or field One2x - * Set chatter side (Optional per user) +* New navigation with an App drawer +* Keyboard shortcuts for easier navigation +* Display kanban views for small screens if an action or field One2x +* Set chatter side (Optional per user) +* Quick search (see below) + +The following keyboard shortcuts are implemented: + +* Toggle App Drawer - `ActionKey ` + ``A`` +* Navigate Apps Drawer - Arrow Keys +* Type to select App Links +* ``esc`` to close App Drawer + +The search feature provided in the App Drawer allows you to easily +navigate menus without a mouse. + +To activate the search, just begin typing while within the App Drawer. +You can use the arrow keys or mouse to navigate and select results, +in a similar fashion to navigating the apps in the drawer. diff --git a/web_responsive/readme/ROADMAP.rst b/web_responsive/readme/ROADMAP.rst index 3c80edcd..189811a8 100644 --- a/web_responsive/readme/ROADMAP.rst +++ b/web_responsive/readme/ROADMAP.rst @@ -13,3 +13,10 @@ this module. * Sticky header and footer in list view only works on certain browsers: https://caniuse.com/#search=sticky (note that the used feature is in `thead`). +* The ``AppDrawer`` JavaScript object currently extends ``Class``. + We should extend ``Widget`` instead. +* On Android (FireFox) - clicking the search icon does not + focus the search input. +* On Android (FireFox & Chrome) - clicking the search query input will + show the on screen keyboard for a split second, but the App Drawer + immediately closes and the keyboard closes with it. diff --git a/web_responsive/static/lib/css/drawer.3.2.0.css b/web_responsive/static/lib/css/drawer.3.2.2.css similarity index 96% rename from web_responsive/static/lib/css/drawer.3.2.0.css rename to web_responsive/static/lib/css/drawer.3.2.2.css index 99705b3a..d2c86471 100644 --- a/web_responsive/static/lib/css/drawer.3.2.0.css +++ b/web_responsive/static/lib/css/drawer.3.2.2.css @@ -1,5 +1,5 @@ /*! - * jquery-drawer v3.2.0 + * jquery-drawer v3.2.2 * Flexible drawer menu using jQuery, iScroll and CSS. * http://git.blivesta.com/drawer * License : MIT @@ -9,6 +9,11 @@ /*!------------------------------------*\ Base \*!------------------------------------*/ + +.drawer-open { + overflow: hidden !important; +} + .drawer-nav { position: fixed; z-index: 101; @@ -52,6 +57,7 @@ } /*! overlay */ + .drawer-overlay { position: fixed; z-index: 100; @@ -67,17 +73,10 @@ display: block; } -/* XXX: local patch waiting for: - https://github.com/blivesta/drawer/pull/36 -*/ -.drawer-open { - overflow: hidden; -} -/* end local patch */ - /*!------------------------------------*\ Top \*!------------------------------------*/ + .drawer--top .drawer-nav { top: -100%; left: 0; @@ -96,9 +95,11 @@ .drawer--top.drawer-open .drawer-hamburger { right: 0; } + /*!------------------------------------*\ Left \*!------------------------------------*/ + .drawer--left .drawer-nav { left: -16.25rem; -webkit-transition: left .6s cubic-bezier(0.190, 1.000, 0.220, 1.000); @@ -114,9 +115,11 @@ .drawer--left.drawer-open .drawer-hamburger { left: 16.25rem; } + /*!------------------------------------*\ Right \*!------------------------------------*/ + .drawer--right .drawer-nav { right: -16.25rem; -webkit-transition: right .6s cubic-bezier(0.190, 1.000, 0.220, 1.000); @@ -132,9 +135,11 @@ .drawer--right.drawer-open .drawer-hamburger { right: 16.25rem; } + /*!------------------------------------*\ Hamburger \*!------------------------------------*/ + .drawer-hamburger { position: fixed; z-index: 104; @@ -200,15 +205,14 @@ .drawer-open .drawer-hamburger-icon:before { -webkit-transform: rotate(45deg); - -ms-transform: rotate(45deg); transform: rotate(45deg); } .drawer-open .drawer-hamburger-icon:after { -webkit-transform: rotate(-45deg); - -ms-transform: rotate(-45deg); transform: rotate(-45deg); } + /*!------------------------------------*\ accessibility \*!------------------------------------*/ @@ -217,6 +221,7 @@ * Only display content to screen readers * See: http://a11yproject.com/posts/how-to-hide-content */ + .sr-only { position: absolute; overflow: hidden; @@ -233,6 +238,7 @@ * Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 * Credit: HTML5 Boilerplate */ + .sr-only-focusable:active, .sr-only-focusable:focus { position: static; @@ -242,9 +248,11 @@ height: auto; margin: 0; } + /*!------------------------------------*\ Sidebar \*!------------------------------------*/ + .drawer--sidebar { background-color: #fff; } @@ -262,7 +270,6 @@ .drawer--sidebar .drawer-nav { display: block; -webkit-transform: none; - -ms-transform: none; transform: none; position: fixed; width: 12.5rem; @@ -313,9 +320,11 @@ max-width: 60rem; } } + /*!------------------------------------*\ Navbar \*!------------------------------------*/ + .drawer--navbarTopGutter { padding-top: 3.75rem; } @@ -332,6 +341,7 @@ } /*! .drawer-navbar modifier */ + .drawer-navbar--fixed { position: fixed; } @@ -426,9 +436,11 @@ padding-left: .75rem; } } + /*!------------------------------------*\ Dropdown \*!------------------------------------*/ + .drawer-dropdown-menu { display: none; box-sizing: border-box; @@ -460,11 +472,13 @@ } /*! open */ + .drawer-dropdown.open > .drawer-dropdown-menu { display: block; } /*! drawer-caret */ + .drawer-dropdown .drawer-caret { display: inline-block; width: 0; @@ -475,7 +489,6 @@ transition: transform .2s ease, opacity .2s ease; transition: transform .2s ease, opacity .2s ease, -webkit-transform .2s ease; -webkit-transform: rotate(0deg); - -ms-transform: rotate(0deg); transform: rotate(0deg); vertical-align: middle; border-top: 4px solid; @@ -484,14 +497,16 @@ } /*! open */ + .drawer-dropdown.open .drawer-caret { -webkit-transform: rotate(180deg); - -ms-transform: rotate(180deg); transform: rotate(180deg); } + /*!------------------------------------*\ Container \*!------------------------------------*/ + .drawer-container { margin-right: auto; margin-left: auto; diff --git a/web_responsive/static/lib/js/bililiteRange.2.6.js b/web_responsive/static/lib/js/bililiteRange.2.6.js new file mode 100644 index 00000000..695f5876 --- /dev/null +++ b/web_responsive/static/lib/js/bililiteRange.2.6.js @@ -0,0 +1,765 @@ +// Cross-broswer implementation of text ranges and selections +// documentation: http://bililite.com/blog/2011/01/17/cross-browser-text-ranges-and-selections/ +// Version: 2.6 +// Copyright (c) 2013 Daniel Wachsstock +// MIT license: +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: + +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +(function(){ + +// a bit of weirdness with IE11: using 'focus' is flaky, even if I'm not bubbling, as far as I can tell. +var focusEvent = 'onfocusin' in document.createElement('input') ? 'focusin' : 'focus'; + +// IE11 normalize is buggy (http://connect.microsoft.com/IE/feedback/details/809424/node-normalize-removes-text-if-dashes-are-present) +var n = document.createElement('div'); +n.appendChild(document.createTextNode('x-')); +n.appendChild(document.createTextNode('x')); +n.normalize(); +var canNormalize = n.firstChild.length == 3; + + +bililiteRange = function(el, debug){ + var ret; + if (debug){ + ret = new NothingRange(); // Easier to force it to use the no-selection type than to try to find an old browser + }else if (window.getSelection && el.setSelectionRange){ + // Standards. Element is an input or textarea + // note that some input elements do not allow selections + try{ + el.selectionStart; // even getting the selection in such an element will throw + ret = new InputRange(); + }catch(e){ + ret = new NothingRange(); + } + }else if (window.getSelection){ + // Standards, with any other kind of element + ret = new W3CRange(); + }else if (document.selection){ + // Internet Explorer + ret = new IERange(); + }else{ + // doesn't support selection + ret = new NothingRange(); + } + ret._el = el; + // determine parent document, as implemented by John McLear + ret._doc = el.ownerDocument; + ret._win = 'defaultView' in ret._doc ? ret._doc.defaultView : ret._doc.parentWindow; + ret._textProp = textProp(el); + ret._bounds = [0, ret.length()]; + // There's no way to detect whether a focus event happened as a result of a click (which should change the selection) + // or as a result of a keyboard event (a tab in) or a script action (el.focus()). So we track it globally, which is a hack, and is likely to fail + // in edge cases (right-clicks, drag-n-drop), and is vulnerable to a lower-down handler preventing bubbling. + // I just don't know a better way. + // I'll hack my event-listening code below, rather than create an entire new bilililiteRange, potentially before the DOM has loaded + if (!('bililiteRangeMouseDown' in ret._doc)){ + var _doc = {_el: ret._doc}; + ret._doc.bililiteRangeMouseDown = false; + bililiteRange.fn.listen.call(_doc, 'mousedown', function() { + ret._doc.bililiteRangeMouseDown = true; + }); + bililiteRange.fn.listen.call(_doc, 'mouseup', function() { + ret._doc.bililiteRangeMouseDown = false; + }); + } + // note that bililiteRangeSelection is an array, which means that copying it only copies the address, which points to the original. + // make sure that we never let it (always do return [bililiteRangeSelection[0], bililiteRangeSelection[1]]), which means never returning + // this._bounds directly + if (!('bililiteRangeSelection' in el)){ + // start tracking the selection + function trackSelection(evt){ + if (evt && evt.which == 9){ + // do tabs my way, by restoring the selection + // there's a flash of the browser's selection, but I don't see a way of avoiding that + ret._nativeSelect(ret._nativeRange(el.bililiteRangeSelection)); + }else{ + el.bililiteRangeSelection = ret._nativeSelection(); + } + } + trackSelection(); + // only IE does this right and allows us to grab the selection before blurring + if ('onbeforedeactivate' in el){ + ret.listen('beforedeactivate', trackSelection); + }else{ + // with standards-based browsers, have to listen for every user interaction + ret.listen('mouseup', trackSelection).listen('keyup', trackSelection); + } + ret.listen(focusEvent, function(){ + // restore the correct selection when the element comes into focus (mouse clicks change the position of the selection) + // Note that Firefox will not fire the focus event until the window/tab is active even if el.focus() is called + // https://bugzilla.mozilla.org/show_bug.cgi?id=566671 + if (!ret._doc.bililiteRangeMouseDown){ + ret._nativeSelect(ret._nativeRange(el.bililiteRangeSelection)); + } + }); + } + if (!('oninput' in el)){ + // give IE8 a chance. Note that this still fails in IE11, which has has oninput on contenteditable elements but does not + // dispatch input events. See http://connect.microsoft.com/IE/feedback/details/794285/ie10-11-input-event-does-not-fire-on-div-with-contenteditable-set + // TODO: revisit this when I have IE11 running on my development machine + var inputhack = function() {ret.dispatch({type: 'input', bubbles: true}) }; + ret.listen('keyup', inputhack); + ret.listen('cut', inputhack); + ret.listen('paste', inputhack); + ret.listen('drop', inputhack); + el.oninput = 'patched'; + } + return ret; +} + +function textProp(el){ + // returns the property that contains the text of the element + // note that for elements the text attribute represents the obsolete text color, not the textContent. + // we document that these routines do not work for elements so that should not be relevant + + // Bugfix for https://github.com/dwachss/bililiteRange/issues/18 + // Adding typeof check of string for el.value in case for li elements + if (typeof el.value === 'string') return 'value'; + if (typeof el.text != 'undefined') return 'text'; + if (typeof el.textContent != 'undefined') return 'textContent'; + return 'innerText'; +} + +// base class +function Range(){} +Range.prototype = { + length: function() { + return this._el[this._textProp].replace(/\r/g, '').length; // need to correct for IE's CrLf weirdness + }, + bounds: function(s){ + if (bililiteRange.bounds[s]){ + this._bounds = bililiteRange.bounds[s].apply(this); + }else if (s){ + this._bounds = s; // don't do error checking now; things may change at a moment's notice + }else{ + var b = [ + Math.max(0, Math.min (this.length(), this._bounds[0])), + Math.max(0, Math.min (this.length(), this._bounds[1])) + ]; + b[1] = Math.max(b[0], b[1]); + return b; // need to constrain it to fit + } + return this; // allow for chaining + }, + select: function(){ + var b = this._el.bililiteRangeSelection = this.bounds(); + if (this._el === this._doc.activeElement){ + // only actually select if this element is active! + this._nativeSelect(this._nativeRange(b)); + } + this.dispatch({type: 'select', bubbles: true}); + return this; // allow for chaining + }, + text: function(text, select){ + if (arguments.length){ + var bounds = this.bounds(), el = this._el; + // signal the input per DOM 3 input events, http://www.w3.org/TR/DOM-Level-3-Events/#h4_events-inputevents + // we add another field, bounds, which are the bounds of the original text before being changed. + this.dispatch({type: 'beforeinput', bubbles: true, + data: text, bounds: bounds}); + this._nativeSetText(text, this._nativeRange(bounds)); + if (select == 'start'){ + this.bounds ([bounds[0], bounds[0]]); + }else if (select == 'end'){ + this.bounds ([bounds[0]+text.length, bounds[0]+text.length]); + }else if (select == 'all'){ + this.bounds ([bounds[0], bounds[0]+text.length]); + } + this.dispatch({type: 'input', bubbles: true, + data: text, bounds: bounds}); + return this; // allow for chaining + }else{ + return this._nativeGetText(this._nativeRange(this.bounds())).replace(/\r/g, ''); // need to correct for IE's CrLf weirdness + } + }, + insertEOL: function (){ + this._nativeEOL(); + this._bounds = [this._bounds[0]+1, this._bounds[0]+1]; // move past the EOL marker + return this; + }, + sendkeys: function (text){ + var self = this; + this.data().sendkeysOriginalText = this.text(); + this.data().sendkeysBounds = undefined; + function simplechar (rng, c){ + if (/^{[^}]*}$/.test(c)) c = c.slice(1,-1); // deal with unknown {key}s + for (var i =0; i < c.length; ++i){ + var x = c.charCodeAt(i); + rng.dispatch({type: 'keypress', bubbles: true, keyCode: x, which: x, charCode: x}); + } + rng.text(c, 'end'); + } + text.replace(/{[^}]*}|[^{]+|{/g, function(part){ + (bililiteRange.sendkeys[part] || simplechar)(self, part, simplechar); + }); + this.bounds(this.data().sendkeysBounds); + this.dispatch({type: 'sendkeys', which: text}); + return this; + }, + top: function(){ + return this._nativeTop(this._nativeRange(this.bounds())); + }, + scrollIntoView: function(scroller){ + var top = this.top(); + // scroll into position if necessary + if (this._el.scrollTop > top || this._el.scrollTop+this._el.clientHeight < top){ + if (scroller){ + scroller.call(this._el, top); + }else{ + this._el.scrollTop = top; + } + } + return this; + }, + wrap: function (n){ + this._nativeWrap(n, this._nativeRange(this.bounds())); + return this; + }, + selection: function(text){ + if (arguments.length){ + return this.bounds('selection').text(text, 'end').select(); + }else{ + return this.bounds('selection').text(); + } + }, + clone: function(){ + return bililiteRange(this._el).bounds(this.bounds()); + }, + all: function(text){ + if (arguments.length){ + this.dispatch ({type: 'beforeinput', bubbles: true, data: text}); + this._el[this._textProp] = text; + this.dispatch ({type: 'input', bubbles: true, data: text}); + return this; + }else{ + return this._el[this._textProp].replace(/\r/g, ''); // need to correct for IE's CrLf weirdness + } + }, + element: function() { return this._el }, + // includes a quickie polyfill for CustomEvent for IE that isn't perfect but works for me + // IE10 allows custom events but not "new CustomEvent"; have to do it the old-fashioned way + dispatch: function(opts){ + opts = opts || {}; + var event = document.createEvent ? document.createEvent('CustomEvent') : this._doc.createEventObject(); + event.initCustomEvent && event.initCustomEvent(opts.type, !!opts.bubbles, !!opts.cancelable, opts.detail); + for (var key in opts) event[key] = opts[key]; + // dispatch event asynchronously (in the sense of on the next turn of the event loop; still should be fired in order of dispatch + var el = this._el; + setTimeout(function(){ + try { + el.dispatchEvent ? el.dispatchEvent(event) : el.fireEvent("on" + opts.type, document.createEventObject()); + }catch(e){ + // IE8 will not let me fire custom events at all. Call them directly + var listeners = el['listen'+opts.type]; + if (listeners) for (var i = 0; i < listeners.length; ++i){ + listeners[i].call(el, event); + } + } + }, 0); + return this; + }, + listen: function (type, func){ + var el = this._el; + if (el.addEventListener){ + el.addEventListener(type, func); + }else{ + el.attachEvent("on" + type, func); + // IE8 can't even handle custom events created with createEventObject (though it permits attachEvent), so we have to make our own + var listeners = el['listen'+type] = el['listen'+type] || []; + listeners.push(func); + } + return this; + }, + dontlisten: function (type, func){ + var el = this._el; + if (el.removeEventListener){ + el.removeEventListener(type, func); + }else try{ + el.detachEvent("on" + type, func); + }catch(e){ + var listeners = el['listen'+type]; + if (listeners) for (var i = 0; i < listeners.length; ++i){ + if (listeners[i] === func) listeners[i] = function(){}; // replace with a noop + } + } + return this; + } +}; + +// allow extensions ala jQuery +bililiteRange.fn = Range.prototype; // to allow monkey patching +bililiteRange.extend = function(fns){ + for (fn in fns) Range.prototype[fn] = fns[fn]; +}; + +//bounds functions +bililiteRange.bounds = { + all: function() { return [0, this.length()] }, + start: function () { return [0,0] }, + end: function () { return [this.length(), this.length()] }, + selection: function(){ + if (this._el === this._doc.activeElement){ + this.bounds ('all'); // first select the whole thing for constraining + return this._nativeSelection(); + }else{ + return this._el.bililiteRangeSelection; + } + } +}; + +// sendkeys functions +bililiteRange.sendkeys = { + '{enter}': function (rng){ + rng.dispatch({type: 'keypress', bubbles: true, keyCode: '\n', which: '\n', charCode: '\n'}); + rng.insertEOL(); + }, + '{tab}': function (rng, c, simplechar){ + simplechar(rng, '\t'); // useful for inserting what would be whitespace + }, + '{newline}': function (rng, c, simplechar){ + simplechar(rng, '\n'); // useful for inserting what would be whitespace (and if I don't want to use insertEOL, which does some fancy things) + }, + '{backspace}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0]-1, b[0]]); // no characters selected; it's just an insertion point. Remove the previous character + rng.text('', 'end'); // delete the characters and update the selection + }, + '{del}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0], b[0]+1]); // no characters selected; it's just an insertion point. Remove the next character + rng.text('', 'end'); // delete the characters and update the selection + }, + '{rightarrow}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) ++b[1]; // no characters selected; it's just an insertion point. Move to the right + rng.bounds([b[1], b[1]]); + }, + '{leftarrow}': function (rng){ + var b = rng.bounds(); + if (b[0] == b[1]) --b[0]; // no characters selected; it's just an insertion point. Move to the left + rng.bounds([b[0], b[0]]); + }, + '{selectall}' : function (rng){ + rng.bounds('all'); + }, + '{selection}': function (rng){ + // insert the characters without the sendkeys processing + var s = rng.data().sendkeysOriginalText; + for (var i =0; i < s.length; ++i){ + var x = s.charCodeAt(i); + rng.dispatch({type: 'keypress', bubbles: true, keyCode: x, which: x, charCode: x}); + } + rng.text(s, 'end'); + }, + '{mark}' : function (rng){ + rng.data().sendkeysBounds = rng.bounds(); + } +}; +// Synonyms from the proposed DOM standard (http://www.w3.org/TR/DOM-Level-3-Events-key/) +bililiteRange.sendkeys['{Enter}'] = bililiteRange.sendkeys['{enter}']; +bililiteRange.sendkeys['{Backspace}'] = bililiteRange.sendkeys['{backspace}']; +bililiteRange.sendkeys['{Delete}'] = bililiteRange.sendkeys['{del}']; +bililiteRange.sendkeys['{ArrowRight}'] = bililiteRange.sendkeys['{rightarrow}']; +bililiteRange.sendkeys['{ArrowLeft}'] = bililiteRange.sendkeys['{leftarrow}']; + +function IERange(){} +IERange.prototype = new Range(); +IERange.prototype._nativeRange = function (bounds){ + var rng; + if (this._el.tagName == 'INPUT'){ + // IE 8 is very inconsistent; textareas have createTextRange but it doesn't work + rng = this._el.createTextRange(); + }else{ + rng = this._doc.body.createTextRange (); + rng.moveToElementText(this._el); + } + if (bounds){ + if (bounds[1] < 0) bounds[1] = 0; // IE tends to run elements out of bounds + if (bounds[0] > this.length()) bounds[0] = this.length(); + if (bounds[1] < rng.text.replace(/\r/g, '').length){ // correct for IE's CrLf weirdness + // block-display elements have an invisible, uncounted end of element marker, so we move an extra one and use the current length of the range + rng.moveEnd ('character', -1); + rng.moveEnd ('character', bounds[1]-rng.text.replace(/\r/g, '').length); + } + if (bounds[0] > 0) rng.moveStart('character', bounds[0]); + } + return rng; +}; +IERange.prototype._nativeSelect = function (rng){ + rng.select(); +}; +IERange.prototype._nativeSelection = function (){ + // returns [start, end] for the selection constrained to be in element + var rng = this._nativeRange(); // range of the element to constrain to + var len = this.length(); + var sel = this._doc.selection.createRange(); + try{ + return [ + iestart(sel, rng), + ieend (sel, rng) + ]; + }catch (e){ + // TODO: determine if this is still necessary, since we only call _nativeSelection if _el is active + // IE gets upset sometimes about comparing text to input elements, but the selections cannot overlap, so make a best guess + return (sel.parentElement().sourceIndex < this._el.sourceIndex) ? [0,0] : [len, len]; + } +}; +IERange.prototype._nativeGetText = function (rng){ + return rng.text; +}; +IERange.prototype._nativeSetText = function (text, rng){ + rng.text = text; +}; +IERange.prototype._nativeEOL = function(){ + if ('value' in this._el){ + this.text('\n'); // for input and textarea, insert it straight + }else{ + this._nativeRange(this.bounds()).pasteHTML('\n
'); + } +}; +IERange.prototype._nativeTop = function(rng){ + var startrng = this._nativeRange([0,0]); + return rng.boundingTop - startrng.boundingTop; +} +IERange.prototype._nativeWrap = function(n, rng) { + // hacky to use string manipulation but I don't see another way to do it. + var div = document.createElement('div'); + div.appendChild(n); + // insert the existing range HTML after the first tag + var html = div.innerHTML.replace('><', '>'+rng.htmlText+'<'); + rng.pasteHTML(html); +}; + +// IE internals +function iestart(rng, constraint){ + // returns the position (in character) of the start of rng within constraint. If it's not in constraint, returns 0 if it's before, length if it's after + var len = constraint.text.replace(/\r/g, '').length; // correct for IE's CrLf weirdness + if (rng.compareEndPoints ('StartToStart', constraint) <= 0) return 0; // at or before the beginning + if (rng.compareEndPoints ('StartToEnd', constraint) >= 0) return len; + for (var i = 0; rng.compareEndPoints ('StartToStart', constraint) > 0; ++i, rng.moveStart('character', -1)); + return i; +} +function ieend (rng, constraint){ + // returns the position (in character) of the end of rng within constraint. If it's not in constraint, returns 0 if it's before, length if it's after + var len = constraint.text.replace(/\r/g, '').length; // correct for IE's CrLf weirdness + if (rng.compareEndPoints ('EndToEnd', constraint) >= 0) return len; // at or after the end + if (rng.compareEndPoints ('EndToStart', constraint) <= 0) return 0; + for (var i = 0; rng.compareEndPoints ('EndToStart', constraint) > 0; ++i, rng.moveEnd('character', -1)); + return i; +} + +// an input element in a standards document. "Native Range" is just the bounds array +function InputRange(){} +InputRange.prototype = new Range(); +InputRange.prototype._nativeRange = function(bounds) { + return bounds || [0, this.length()]; +}; +InputRange.prototype._nativeSelect = function (rng){ + this._el.setSelectionRange(rng[0], rng[1]); +}; +InputRange.prototype._nativeSelection = function(){ + return [this._el.selectionStart, this._el.selectionEnd]; +}; +InputRange.prototype._nativeGetText = function(rng){ + return this._el.value.substring(rng[0], rng[1]); +}; +InputRange.prototype._nativeSetText = function(text, rng){ + var val = this._el.value; + this._el.value = val.substring(0, rng[0]) + text + val.substring(rng[1]); +}; +InputRange.prototype._nativeEOL = function(){ + this.text('\n'); +}; +InputRange.prototype._nativeTop = function(rng){ + // I can't remember where I found this clever hack to find the location of text in a text area + var clone = this._el.cloneNode(true); + clone.style.visibility = 'hidden'; + clone.style.position = 'absolute'; + this._el.parentNode.insertBefore(clone, this._el); + clone.style.height = '1px'; + clone.value = this._el.value.slice(0, rng[0]); + var top = clone.scrollHeight; + // this gives the bottom of the text, so we have to subtract the height of a single line + clone.value = 'X'; + top -= clone.scrollHeight; + clone.parentNode.removeChild(clone); + return top; +} +InputRange.prototype._nativeWrap = function() {throw new Error("Cannot wrap in a text element")}; + +function W3CRange(){} +W3CRange.prototype = new Range(); +W3CRange.prototype._nativeRange = function (bounds){ + var rng = this._doc.createRange(); + rng.selectNodeContents(this._el); + if (bounds){ + w3cmoveBoundary (rng, bounds[0], true, this._el); + rng.collapse (true); + w3cmoveBoundary (rng, bounds[1]-bounds[0], false, this._el); + } + return rng; +}; +W3CRange.prototype._nativeSelect = function (rng){ + this._win.getSelection().removeAllRanges(); + this._win.getSelection().addRange (rng); +}; +W3CRange.prototype._nativeSelection = function (){ + // returns [start, end] for the selection constrained to be in element + var rng = this._nativeRange(); // range of the element to constrain to + if (this._win.getSelection().rangeCount == 0) return [this.length(), this.length()]; // append to the end + var sel = this._win.getSelection().getRangeAt(0); + return [ + w3cstart(sel, rng), + w3cend (sel, rng) + ]; + } +W3CRange.prototype._nativeGetText = function (rng){ + return String.prototype.slice.apply(this._el.textContent, this.bounds()); + // return rng.toString(); // this fails in IE11 since it insists on inserting \r's before \n's in Ranges. node.textContent works as expected +}; +W3CRange.prototype._nativeSetText = function (text, rng){ + rng.deleteContents(); + rng.insertNode (this._doc.createTextNode(text)); + if (canNormalize) this._el.normalize(); // merge the text with the surrounding text +}; +W3CRange.prototype._nativeEOL = function(){ + var rng = this._nativeRange(this.bounds()); + rng.deleteContents(); + var br = this._doc.createElement('br'); + br.setAttribute ('_moz_dirty', ''); // for Firefox + rng.insertNode (br); + rng.insertNode (this._doc.createTextNode('\n')); + rng.collapse (false); +}; +W3CRange.prototype._nativeTop = function(rng){ + if (this.length == 0) return 0; // no text, no scrolling + if (rng.toString() == ''){ + var textnode = this._doc.createTextNode('X'); + rng.insertNode (textnode); + } + var startrng = this._nativeRange([0,1]); + var top = rng.getBoundingClientRect().top - startrng.getBoundingClientRect().top; + if (textnode) textnode.parentNode.removeChild(textnode); + return top; +} +W3CRange.prototype._nativeWrap = function(n, rng) { + rng.surroundContents(n); +}; + +// W3C internals +function nextnode (node, root){ + // in-order traversal + // we've already visited node, so get kids then siblings + if (node.firstChild) return node.firstChild; + if (node.nextSibling) return node.nextSibling; + if (node===root) return null; + while (node.parentNode){ + // get uncles + node = node.parentNode; + if (node == root) return null; + if (node.nextSibling) return node.nextSibling; + } + return null; +} +function w3cmoveBoundary (rng, n, bStart, el){ + // move the boundary (bStart == true ? start : end) n characters forward, up to the end of element el. Forward only! + // if the start is moved after the end, then an exception is raised + if (n <= 0) return; + var node = rng[bStart ? 'startContainer' : 'endContainer']; + if (node.nodeType == 3){ + // we may be starting somewhere into the text + n += rng[bStart ? 'startOffset' : 'endOffset']; + } + while (node){ + if (node.nodeType == 3){ + var length = node.nodeValue.length; + if (n <= length){ + rng[bStart ? 'setStart' : 'setEnd'](node, n); + // special case: if we end next to a
, include that node. + if (n == length){ + // skip past zero-length text nodes + for (var next = nextnode (node, el); next && next.nodeType==3 && next.nodeValue.length == 0; next = nextnode(next, el)){ + rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + } + if (next && next.nodeType == 1 && next.nodeName == "BR") rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + } + return; + }else{ + rng[bStart ? 'setStartAfter' : 'setEndAfter'](node); // skip past this one + n -= length; // and eat these characters + } + } + node = nextnode (node, el); + } +} +var START_TO_START = 0; // from the w3c definitions +var START_TO_END = 1; +var END_TO_END = 2; +var END_TO_START = 3; +// from the Mozilla documentation, for range.compareBoundaryPoints(how, sourceRange) +// -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. + // * Range.END_TO_END compares the end boundary-point of sourceRange to the end boundary-point of range. + // * Range.END_TO_START compares the end boundary-point of sourceRange to the start boundary-point of range. + // * Range.START_TO_END compares the start boundary-point of sourceRange to the end boundary-point of range. + // * Range.START_TO_START compares the start boundary-point of sourceRange to the start boundary-point of range. +function w3cstart(rng, constraint){ + if (rng.compareBoundaryPoints (START_TO_START, constraint) <= 0) return 0; // at or before the beginning + if (rng.compareBoundaryPoints (END_TO_START, constraint) >= 0) return constraint.toString().length; + rng = rng.cloneRange(); // don't change the original + rng.setEnd (constraint.endContainer, constraint.endOffset); // they now end at the same place + return constraint.toString().replace(/\r/g, '').length - rng.toString().replace(/\r/g, '').length; +} +function w3cend (rng, constraint){ + if (rng.compareBoundaryPoints (END_TO_END, constraint) >= 0) return constraint.toString().length; // at or after the end + if (rng.compareBoundaryPoints (START_TO_END, constraint) <= 0) return 0; + rng = rng.cloneRange(); // don't change the original + rng.setStart (constraint.startContainer, constraint.startOffset); // they now start at the same place + return rng.toString().replace(/\r/g, '').length; +} + +function NothingRange(){} +NothingRange.prototype = new Range(); +NothingRange.prototype._nativeRange = function(bounds) { + return bounds || [0,this.length()]; +}; +NothingRange.prototype._nativeSelect = function (rng){ // do nothing +}; +NothingRange.prototype._nativeSelection = function(){ + return [0,0]; +}; +NothingRange.prototype._nativeGetText = function (rng){ + return this._el[this._textProp].substring(rng[0], rng[1]); +}; +NothingRange.prototype._nativeSetText = function (text, rng){ + var val = this._el[this._textProp]; + this._el[this._textProp] = val.substring(0, rng[0]) + text + val.substring(rng[1]); +}; +NothingRange.prototype._nativeEOL = function(){ + this.text('\n'); +}; +NothingRange.prototype._nativeTop = function(){ + return 0; +}; +NothingRange.prototype._nativeWrap = function() {throw new Error("Wrapping not implemented")}; + + +// data for elements, similar to jQuery data, but allows for monitoring with custom events +var data = []; // to avoid attaching javascript objects to DOM elements, to avoid memory leaks +bililiteRange.fn.data = function(){ + var index = this.element().bililiteRangeData; + if (index == undefined){ + index = this.element().bililiteRangeData = data.length; + data[index] = new Data(this); + } + return data[index]; +} +try { + Object.defineProperty({},'foo',{}); // IE8 will throw an error + var Data = function(rng) { + // we use JSON.stringify to display the data values. To make some of those non-enumerable, we have to use properties + Object.defineProperty(this, 'values', { + value: {} + }); + Object.defineProperty(this, 'sourceRange', { + value: rng + }); + Object.defineProperty(this, 'toJSON', { + value: function(){ + var ret = {}; + for (var i in Data.prototype) if (i in this.values) ret[i] = this.values[i]; + return ret; + } + }); + // to display all the properties (not just those changed), use JSON.stringify(state.all) + Object.defineProperty(this, 'all', { + get: function(){ + var ret = {}; + for (var i in Data.prototype) ret[i] = this[i]; + return ret; + } + }); + } + + Data.prototype = {}; + Object.defineProperty(Data.prototype, 'values', { + value: {} + }); + Object.defineProperty(Data.prototype, 'monitored', { + value: {} + }); + + bililiteRange.data = function (name, newdesc){ + newdesc = newdesc || {}; + var desc = Object.getOwnPropertyDescriptor(Data.prototype, name) || {}; + if ('enumerable' in newdesc) desc.enumerable = !!newdesc.enumerable; + if (!('enumerable' in desc)) desc.enumerable = true; // default + if ('value' in newdesc) Data.prototype.values[name] = newdesc.value; + if ('monitored' in newdesc) Data.prototype.monitored[name] = newdesc.monitored; + desc.configurable = true; + desc.get = function (){ + if (name in this.values) return this.values[name]; + return Data.prototype.values[name]; + }; + desc.set = function (value){ + this.values[name] = value; + if (Data.prototype.monitored[name]) this.sourceRange.dispatch({ + type: 'bililiteRangeData', + bubbles: true, + detail: {name: name, value: value} + }); + } + Object.defineProperty(Data.prototype, name, desc); + } +}catch(err){ + // if we can't set object property properties, just use old-fashioned properties + Data = function(rng){ this.sourceRange = rng }; + Data.prototype = {}; + bililiteRange.data = function(name, newdesc){ + if ('value' in newdesc) Data.prototype[name] = newdesc.value; + } +} + +})(); + +// Polyfill for forEach, per Mozilla documentation. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#Polyfill +if (!Array.prototype.forEach) +{ + Array.prototype.forEach = function(fun /*, thisArg */) + { + "use strict"; + + if (this === void 0 || this === null) + throw new TypeError(); + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== "function") + throw new TypeError(); + + var thisArg = arguments.length >= 2 ? arguments[1] : void 0; + for (var i = 0; i < len; i++) + { + if (i in t) + fun.call(thisArg, t[i], i, t); + } + }; +} diff --git a/web_responsive/static/lib/js/drawer.3.2.0.js b/web_responsive/static/lib/js/drawer.3.2.2.js similarity index 99% rename from web_responsive/static/lib/js/drawer.3.2.0.js rename to web_responsive/static/lib/js/drawer.3.2.2.js index 3d5e1f2f..98e669f7 100644 --- a/web_responsive/static/lib/js/drawer.3.2.0.js +++ b/web_responsive/static/lib/js/drawer.3.2.2.js @@ -1,5 +1,5 @@ /*! - * jquery-drawer v3.2.0 + * jquery-drawer v3.2.2 * Flexible drawer menu using jQuery, iScroll and CSS. * http://git.blivesta.com/drawer * License : MIT diff --git a/web_responsive/static/lib/js/jquery.sendkeys.4.js b/web_responsive/static/lib/js/jquery.sendkeys.4.js new file mode 100644 index 00000000..5c8cd23c --- /dev/null +++ b/web_responsive/static/lib/js/jquery.sendkeys.4.js @@ -0,0 +1,57 @@ +// insert characters in a textarea or text input field +// special characters are enclosed in {}; use {{} for the { character itself +// documentation: http://bililite.com/blog/2008/08/20/the-fnsendkeys-plugin/ +// source: https://github.com/dwachss/bililiteRange/blob/master/jquery.sendkeys.js +// Version: 4 +// Copyright (c) 2013 Daniel Wachsstock +// MIT license: +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: + +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +(function($){ + +$.fn.sendkeys = function (x){ + x = x.replace(/([^{])\n/g, '$1{enter}'); // turn line feeds into explicit break insertions, but not if escaped + return this.each( function(){ + bililiteRange(this).bounds('selection').sendkeys(x).select(); + this.focus(); + }); +}; // sendkeys + +// add a default handler for keydowns so that we can send keystrokes, even though code-generated events +// are untrusted (http://www.w3.org/TR/DOM-Level-3-Events/#trusted-events) +// documentation of special event handlers is at http://learn.jquery.com/events/event-extensions/ +$.event.special.keydown = $.event.special.keydown || {}; +$.event.special.keydown._default = function (evt){ + if (evt.isTrusted) return false; + if (evt.ctrlKey || evt.altKey || evt.metaKey) return false; // only deal with printable characters. This may be a false assumption + if (evt.key == null) return false; // nothing to print. Use the keymap plugin to set this + var target = evt.target; + if (target.isContentEditable || target.nodeName == 'INPUT' || target.nodeName == 'TEXTAREA') { + // only insert into editable elements + var key = evt.key; + if (key.length > 1 && key.charAt(0) != '{') key = '{'+key+'}'; // sendkeys notation + $(target).sendkeys(key); + return true; + } + return false; +} +})(jQuery) diff --git a/web_responsive/static/src/js/web_responsive.js b/web_responsive/static/src/js/web_responsive.js index fc9d7d0d..3b1bde88 100644 --- a/web_responsive/static/src/js/web_responsive.js +++ b/web_responsive/static/src/js/web_responsive.js @@ -6,6 +6,7 @@ odoo.define('web_responsive', function(require) { var Menu = require('web.Menu'); var Class = require('web.Class'); + var rpc = require('web.rpc'); var SearchView = require('web.SearchView'); var core = require('web.core'); var config = require('web.config'); @@ -29,7 +30,7 @@ odoo.define('web_responsive', function(require) { this._super(id); if (allowOpen) { return; - } + }; var $clicked_menu = this.$secondary_menus.find('a[data-menu=' + id + ']'); $clicked_menu.parents('.oe_secondary_submenu').css('display', ''); } @@ -65,19 +66,43 @@ odoo.define('web_responsive', function(require) { var AppDrawer = Class.extend({ + /* Provides all features inside of the application drawer navigation. + + Attributes: + directionCodes (str): Canonical key name to direction mappings. + deleteCodes + */ + LEFT: 'left', RIGHT: 'right', UP: 'up', DOWN: 'down', + // These keys are ignored when presented as single input + MODIFIERS: [ + 'Alt', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'Control', + 'Enter', + 'Escape', + 'Meta', + 'Shift', + 'Tab', + ], + isOpen: false, keyBuffer: '', keyBufferTime: 500, keyBufferTimeoutEvent: false, dropdownHeightFactor: 0.90, initialized: false, + searching: false, init: function() { + this.directionCodes = { 'left': this.LEFT, 'right': this.RIGHT, @@ -88,6 +113,10 @@ odoo.define('web_responsive', function(require) { '+': this.RIGHT, '-': this.LEFT }; + this.$searchAction = $('.app-drawer-search-action'); + this.$searchAction.hide(); + this.$searchResultsContainer = $('#appDrawerSearchResults'); + this.$searchInput = $('#appDrawerSearchInput'); this.initDrawer(); this.handleWindowResize(); var $clickZones = $('.odoo_webclient_container, ' + @@ -97,49 +126,75 @@ odoo.define('web_responsive', function(require) { 'i.oe_logo_edit' ); $clickZones.click($.proxy(this.handleClickZones, this)); + this.$searchResultsContainer.click($.proxy(this.searchMenus, this)); + this.$el.find('.drawer-search-open').click( + $.proxy(this.searchMenus, this) + ); + this.$el.find('.drawer-search-close').hide().click( + $.proxy(this.closeSearchMenus, this) + ); + core.bus.on('resize', this, this.handleWindowResize); - core.bus.on('keydown', this, this.handleNavKeys); + core.bus.on('keydown', this, this.handleKeyDown); + core.bus.on('keyup', this, this.redirectKeyPresses); + core.bus.on('keypress', this, this.redirectKeyPresses); }, - // It provides initialization handlers for Drawer + // Provides initialization handlers for Drawer initDrawer: function() { this.$el = $('.drawer'); this.$el.drawer(); this.$el.one('drawer.opened', $.proxy(this.onDrawerOpen, this)); - this.$el.on('drawer.opened', function setIScrollProbes() { - var onIScroll = function() { - var transform = this.iScroll.y ? this.iScroll.y * -1 : 0; - $(this).find('#appDrawerAppPanelHead').css( - 'transform', 'matrix(1, 0, 0, 1, 0, ' + transform + ')' + + // Setup the iScroll options. + // You should be able to pass these to ``.drawer``, but scroll freezes. + this.$el.on( + 'drawer.opened', + function setIScrollProbes(){ + var onIScroll = $.proxy( + function() { + this.iScroll.refresh(); + }, + this ); - }; - // Scroll probe aggressiveness level - // 2 == always executes the scroll event except during momentum and bounce. - this.iScroll.options.probeType = 2; - // Set options because - this.iScroll.on('scroll', $.proxy(onIScroll, this)); - }); + // Scroll probe aggressiveness level + // 2 == always executes the scroll event except during momentum and bounce. + this.iScroll.options.probeType = 2; + this.iScroll.on('scroll', onIScroll); + // Initialize Scrollbars manually + this.iScroll.options.scrollbars = true; + this.iScroll.options.fadeScrollbars = true; + this.iScroll._initIndicators(); + } + ); this.initialized = true; }, - // It provides handlers to hide drawer when "unfocused" + // Provides handlers to hide drawer when "unfocused" handleClickZones: function() { this.$el.drawer('close'); $('.o_sub_menu_content') .parent() .collapse('hide'); + $('.navbar-collapse').collapse('hide'); }, - // It resizes bootstrap dropdowns for screen + // Resizes bootstrap dropdowns for screen handleWindowResize: function() { $('.dropdown-scrollable').css( 'max-height', $(window).height() * this.dropdownHeightFactor ); }, - // It provides keyboard shortcuts for app drawer nav - handleNavKeys: function(e) { - if (!this.isOpen) { + /* Provide keyboard shortcuts for app drawer nav. + * + * It is required to perform this functionality only on the ``keydown`` + * event in order to prevent duplication of the arrow events. + * + * @param e The ``keydown`` event triggered by ``core.bus``. + */ + handleKeyDown: function(e) { + if (!this.isOpen){ return; } var directionCode = $.hotkeys.specialKeys[e.keyCode.toString()]; @@ -151,36 +206,87 @@ odoo.define('web_responsive', function(require) { this.selectAppLink($link); } else if ($.hotkeys.specialKeys[e.keyCode.toString()] === 'esc') { this.handleClickZones(); + if (this.searching) { + var $collection = this.$el.find('#appDrawerMenuSearch a'); + var $link = this.findAdjacentLink( + this.$el.find('#appDrawerMenuSearch a:first, #appDrawerMenuSearch a.web-responsive-focus').last(), + this.directionCodes[directionCode], + $collection, + true + ); + } else { + var $link = this.findAdjacentLink( + this.$el.find('#appDrawerApps a:first, #appDrawerApps a.web-responsive-focus').last(), + this.directionCodes[directionCode] + ); + } + this.selectLink($link); + } else if ($.hotkeys.specialKeys[e.keyCode.toString()] == 'esc') { + // We either back out of the search, or close the app drawer. + if (this.searching) { + this.closeSearchMenus(); + } else { + this.handleClickZones(); + } } else { - var buffer = this.handleKeyBuffer(e.keyCode); - this.selectAppLink(this.searchAppLinks(buffer)); + this.redirectKeyPresses(e); } }, - /* It adds to keybuffer, sets expire timer, and returns buffer - * @returns str of current buffer + /* Provide centralized key event redirects for the App Drawer. + * + * This method is for all key events not related to arrow navigation. + * + * @param e The key event that was triggered by ``core.bus``. */ - handleKeyBuffer: function(keyCode) { - this.keyBuffer += String.fromCharCode(keyCode); - if (this.keyBufferTimeoutEvent) { - clearTimeout(this.keyBufferTimeoutEvent); + redirectKeyPresses: function(e) { + + if ( !this.isOpen ) { + // Drawer isn't open; Ignore. + return; } - this.keyBufferTimeoutEvent = setTimeout( - $.proxy(this.clearKeyBuffer, this), - this.keyBufferTime - ); - return this.keyBuffer; - }, - clearKeyBuffer: function() { - this.keyBuffer = ''; + // Trigger navigation to pseudo-focused link + // & fake a click (in case of anchor link). + if (e.key === 'Enter') { + window.location.href = $('.web-responsive-focus').attr('href'); + this.handleClickZones(); + return; + } + + // Ignore any other modifier keys. + if (this.MODIFIERS.indexOf(e.key) !== -1) { + return; + } + + // Event is already targeting the search input. + // Perform search, then stop processing. + if ( e.target === this.$searchInput[0] ) { + this.searchMenus(); + return; + } + + // Prevent default event, + // redirect it to the search input, + // and search. + e.preventDefault(); + this.$searchInput.trigger({ + type: e.type, + key: e.key, + keyCode: e.keyCode, + which: e.which, + }); + this.searchMenus(); + }, - /* It performs close actions + /* Performs close actions * @fires ``drawer.closed`` to the ``core.bus`` * @listens ``drawer.opened`` and sends to onDrawerOpen */ onDrawerClose: function() { + this.closeSearchMenus(); + this.$searchAction.hide(); core.bus.trigger('drawer.closed'); this.$el.one('drawer.opened', $.proxy(this.onDrawerOpen, this)); this.isOpen = false; @@ -188,36 +294,88 @@ odoo.define('web_responsive', function(require) { this.$el.css("overflow", ""); }, - /* It finds app links and register event handlers + /* Finds app links and register event handlers * @fires ``drawer.opened`` to the ``core.bus`` * @listens ``drawer.closed`` and sends to :meth:``onDrawerClose`` */ onDrawerOpen: function() { this.$appLinks = $('.app-drawer-icon-app').parent(); - this.selectAppLink($(this.$appLinks[0])); + this.selectLink($(this.$appLinks[0])); this.$el.one('drawer.closed', $.proxy(this.onDrawerClose, this)); core.bus.trigger('drawer.opened'); this.isOpen = true; }, - // It selects an app link visibly - selectAppLink: function($appLink) { - if ($appLink) { - $appLink.focus(); + // Selects a link visibly & deselects others. + selectLink: function($link) { + $('.web-responsive-focus').removeClass('web-responsive-focus'); + if ($link) { + $link.addClass('web-responsive-focus'); } }, - /* It returns first App Link by its name according to query + /* Searches for menus by name, then triggers showFoundMenus * @param query str to search * @return jQuery obj */ - searchAppLinks: function(query) { - return this.$appLinks.filter(function() { - return $(this).data('menuName').toUpperCase().startsWith(query); - }).first(); + searchMenus: function() { + this.$searchInput = $('#appDrawerSearchInput').focus(); + var domain = [['name', 'ilike', this.$searchInput.val()], + ['action', '!=', false]]; + rpc.query({ + model: 'ir.ui.menu', + method: 'search_read', + args: [{ + fields: ['action', 'display_name', 'id'], + domain: domain + + }] + }).then( + $.proxy(this.showFoundMenus, this) + ); + }, + + /* Display the menus that are provided as input. + */ + showFoundMenus: function(menus) { + this.searching = true; + this.$el.find('#appDrawerApps').hide(); + this.$searchAction.hide(); + this.$el.find('.drawer-search-close').show(); + this.$el.find('.drawer-search-open').hide(); + this.$searchResultsContainer + // Render the results + .html( + core.qweb.render( + 'AppDrawerMenuSearchResults', + {menus: menus} + ) + ) + // Get the parent container and show it. + .closest('#appDrawerMenuSearch') + .show() + // Find the input, set focus. + .find('.menu-search-query') + .focus() + ; + var $menuLinks = this.$searchResultsContainer.find('a'); + $menuLinks.click($.proxy(this.handleClickZones, this)); + this.selectLink($menuLinks.first()); }, - /* It returns the link adjacent to $appLink in provided direction. + /* Close search menu and switch back to app menu. + */ + closeSearchMenus: function() { + this.searching = false; + this.$el.find('#appDrawerApps').show(); + this.$el.find('.drawer-search-close').hide(); + this.$el.find('.drawer-search-open').show(); + this.$searchResultsContainer.closest('#appDrawerMenuSearch').hide(); + this.$searchAction.show(); + $('#appDrawerSearchInput').val(''); + }, + + /* Returns the link adjacent to $link in provided direction. * It also handles edge cases in the following ways: * * Moves to last link if LEFT on first * * Moves to first link if PREV on last @@ -225,40 +383,46 @@ odoo.define('web_responsive', function(require) { * * Moves to last link of previous row if LEFT on first in row * * Moves to top link in same column if DOWN on bottom row * * Moves to bottom link in same column if UP on top row - * @param $appLink jQuery obj of App icon link + * @param $link jQuery obj of App icon link * @param direction str of direction to go (constants LEFT, UP, etc.) - * @return jQuery obj for adjacent applink + * @param $objs jQuery obj representing the collection of links. Defaults + * to `this.$appLinks`. + * @param restrictHorizontal bool Set to true if the collection consists + * only of vertical elements. + * @return jQuery obj for adjacent link */ - findAdjacentAppLink: function($appLink, direction) { + findAdjacentLink: function($link, direction, $objs, restrictHorizontal) { - var obj = [], + if ($objs === undefined) { $objs = this.$appLinks; + } + + var obj = []; + var $rows = (restrictHorizontal) ? $objs : this.getRowObjs($link, this.$appLinks); switch (direction) { case this.LEFT: - obj = $objs[$objs.index($appLink) - 1]; + obj = $objs[$objs.index($link) - 1]; if (!obj) { obj = $objs[$objs.length - 1]; } break; case this.RIGHT: - obj = $objs[$objs.index($appLink) + 1]; + obj = $objs[$objs.index($link) + 1]; if (!obj) { obj = $objs[0]; } break; case this.UP: - $objs = this.getRowObjs($appLink, this.$appLinks); - obj = $objs[$objs.index($appLink) - 1]; + obj = $rows[$rows.index($link) - 1]; if (!obj) { - obj = $objs[$objs.length - 1]; + obj = $rows[$rows.length - 1]; } break; case this.DOWN: - $objs = this.getRowObjs($appLink, this.$appLinks); - obj = $objs[$objs.index($appLink) + 1]; + obj = $rows[$rows.index($link) + 1]; if (!obj) { - obj = $objs[0]; + obj = $rows[0]; } break; } @@ -271,7 +435,7 @@ odoo.define('web_responsive', function(require) { }, - /* It returns els in the same row + /* Returns els in the same row * @param @obj jQuery object to get row for * @param $grid jQuery objects representing grid * @return $objs jQuery objects of row @@ -292,9 +456,9 @@ odoo.define('web_responsive', function(require) { }); - // It inits a new AppDrawer when the web client is ready - core.bus.on('web_client_ready', null, function() { - return new AppDrawer(); + // Init a new AppDrawer when the web client is ready + core.bus.on('web_client_ready', null, function () { + new AppDrawer(); }); // if we are in small screen change default view to kanban if exists diff --git a/web_responsive/static/src/less/app_drawer.less b/web_responsive/static/src/less/app_drawer.less index cb2b79c1..8d7342c4 100644 --- a/web_responsive/static/src/less/app_drawer.less +++ b/web_responsive/static/src/less/app_drawer.less @@ -78,6 +78,14 @@ width: 100%; } + .app-drawer-search-panel { + + .panel-body { + padding-top: @padding-base-vertical; + } + + } + } .drawer-nav { @@ -113,3 +121,9 @@ .app-drawer-toggle.navbar-toggle { margin-left: 1em; } + +/* Icon Focusing */ + +.web-responsive-focus { + .tab-focus(); +} diff --git a/web_responsive/static/src/xml/app_drawer_menu_search.xml b/web_responsive/static/src/xml/app_drawer_menu_search.xml new file mode 100644 index 00000000..027ff9d9 --- /dev/null +++ b/web_responsive/static/src/xml/app_drawer_menu_search.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/web_responsive/views/assets.xml b/web_responsive/views/assets.xml index f865e793..352d5b01 100644 --- a/web_responsive/views/assets.xml +++ b/web_responsive/views/assets.xml @@ -11,7 +11,7 @@ +