Merge pull request #5161 from opf/accessibility/jquery-ui-autocomplete

Replace bootstrap typeahead with jquery-ui
pull/5164/head
ulferts 8 years ago committed by GitHub
commit 43b79be8f2
  1. 36
      app/assets/stylesheets/content/_autocomplete.sass
  2. 27
      app/assets/stylesheets/content/_typeahead.sass
  3. 1
      app/assets/stylesheets/default.css.sass
  4. 2
      frontend/app/angular-modules.ts
  5. 14
      frontend/app/components/common/autocomplete/users/user-autocomplete-item.html
  6. 52
      frontend/app/components/common/typeahead/op-fullwidth-typeahead.directive.ts
  7. 10
      frontend/app/components/common/typeahead/users/typeahead-match.html
  8. 44
      frontend/app/components/wp-panels/watchers-panel/watchers-panel.controller.ts
  9. 16
      frontend/app/components/wp-panels/watchers-panel/watchers-panel.directive.html
  10. 30
      frontend/app/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.directive.ts
  11. 13
      frontend/app/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.template.html
  12. 1
      frontend/typings.json
  13. 1909
      frontend/typings/globals/jqueryui/index.d.ts
  14. 8
      frontend/typings/globals/jqueryui/typings.json
  15. 1
      frontend/typings/index.d.ts
  16. 14
      spec/features/support/components/ui_autocomplete.rb
  17. 10
      spec/features/work_packages/details/details_relations_spec.rb
  18. 14
      spec/features/work_packages/tabs/watcher_tab_spec.rb

@ -57,3 +57,39 @@ div.autocomplete
#parent_issue_candidates ul li, #related_issue_candidates ul li #parent_issue_candidates ul li, #related_issue_candidates ul li
width: 500px width: 500px
/** jquery-ui autocomplete */
.ui-autocomplete
position: absolute
top: 0
left: 0
cursor: default
margin: 0
list-style: none
.ui-menu-item
border-bottom: 1px solid $content-default-border-color
a
color: $body-font-color
padding-left: 5px
.ui-menu-item-wrapper
word-break: break-word
padding: 10px 5px
&.ui-state-active, &.ui-state-active a
border: none
background: $primary-color-dark
color: $font-color-on-primary-dark
.ui-autocomplete--input
// Error highlighting
&.-error, &.-error:hover, &.-error:focus
background: $nm-color-error-background
border-color: $nm-color-error-border

@ -1,27 +0,0 @@
[uib-typeahead-popup].dropdown-menu
position: absolute
margin: 0
z-index: 100
list-style: none
background: white
border: 1px solid rgba(0,0,0,.15)
border-radius: 4px
box-shadow: 0 6px 12px rgba(0,0,0,.175)
.uib-typeahead-match
padding: 10px 5px
word-break: break-word
&.active
background: $primary-color-dark
a
color: $font-color-on-primary-dark
.typeahead-autocomplete
// Error highlighting
&.-error, &.-error:hover, &.-error:focus
background: $nm-color-error-background
border-color: $nm-color-error-border

@ -77,7 +77,6 @@
@import content/information_section @import content/information_section
@import content/widget_box @import content/widget_box
@import content/list @import content/list
@import content/typeahead
@import content/work_packages @import content/work_packages

@ -166,7 +166,6 @@ export const wpButtonsModule = angular.module('openproject.wpButtons',
// main app // main app
var angularDragula:any = require('angular-dragula'); var angularDragula:any = require('angular-dragula');
var typeahead:any = require('angular-ui-bootstrap/src/typeahead/index-nocss.js');
export const openprojectModule = angular.module('openproject', [ export const openprojectModule = angular.module('openproject', [
'ui.router', 'ui.router',
@ -180,7 +179,6 @@ export const openprojectModule = angular.module('openproject', [
'ngAnimate', 'ngAnimate',
'ngAria', 'ngAria',
'ngSanitize', 'ngSanitize',
typeahead,
angularDragula(angular), angularDragula(angular),
'ngDialog', 'ngDialog',
'truncate', 'truncate',

@ -0,0 +1,14 @@
<li ng-attr-data-value="{{ ::value }}" class="ui-menu-item">
<div class="ui-menu-item-wrapper">
<a href
tabindex="-1"
ng-attr-title="{{ ::watcher.name}}">
<img ng-attr-src="{{ ::watcher.avatar }}"
ng-attr-alt="{{ ::watchername }}"
ng-show="watcher.avatar"
class="avatar-mini">
<span class="work-package--watcher-name"
ng-bind="::value"></span>
</a>
</div>
</li>

@ -1,52 +0,0 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
import {wpDirectivesModule} from '../../../angular-modules';
function opFullWidthTypeahead( $timeout ) {
return {
restrict: 'A',
require: 'uibTypeahead',
link: function(scope, element, attrs, $select) {
const watchOn = attrs['typeaheadIsOpen'];
if (!watchOn) {
throw "Missing typeahead-is-open on typeahead directive!";
}
scope.$watch(watchOn, (isOpen) => {
if (isOpen) {
angular.element('#' + element.attr('aria-owns')).width(element.outerWidth());
}
});
}
};
}
wpDirectivesModule.directive('opFullwidthTypeahead', opFullWidthTypeahead);

@ -1,10 +0,0 @@
<a href
tabindex="-1"
ng-attr-title="{{match.label}}">
<img ng-attr-src="{{ match.model.avatar }}"
ng-attr-alt="{{ match.model.name }}"
ng-show="match.model.avatar"
class="avatar-mini">
<span class="work-package--watcher-name"
ng-bind-html="match.label | uibTypeaheadHighlight:query"></span>
</a>

@ -45,8 +45,11 @@ export class WatchersPanelController {
public text: any; public text: any;
constructor(public $scope, constructor(public $scope,
public $element,
public $q, public $q,
public I18n, public I18n,
public $templateCache:ng.ITemplateCacheService,
public $compile:ng.ICompileService,
public wpNotificationsService: WorkPackageNotificationService, public wpNotificationsService: WorkPackageNotificationService,
public wpCacheService: WorkPackageCacheService) { public wpCacheService: WorkPackageCacheService) {
@ -81,8 +84,46 @@ export class WatchersPanelController {
.catch((error) => { .catch((error) => {
this.wpNotificationsService.showError(error, this.workPackage); this.wpNotificationsService.showError(error, this.workPackage);
}); });
this.setupAutoCompletion();
}; };
public setupAutoCompletion() {
const input = this.$element.find('.ui-autocomplete--input');
input.autocomplete({
delay: 250,
autoFocus: false, // Accessibility!
source: (request:{ term:string }, response:Function) => {
this.autocompleteWatchers(request.term).then((values) => {
response(values.map(watcher => {
return { watcher: watcher, value: watcher.name };
}));
});
},
select: (evt, ui:any) => {
this.addWatcher(ui.item.watcher);
input.val('');
return false; // Avoid setting the value after selection
},
_renderItem: (ul:JQuery, item) => this.renderWatcherItem(ul, item)
})
.autocomplete( "instance" )._renderItem = (ul, item) => this.renderWatcherItem(ul,item);
}
public renderWatcherItem(ul:JQuery, item:{value: string, watcher: any}) {
let itemScope = this.$scope.$new();
itemScope['value'] = item.value;
itemScope['watcher'] = item.watcher;
// Render template
let template = this.$templateCache.get('/components/common/autocomplete/users/user-autocomplete-item.html');
let element = angular.element(template);
ul.append(element);
this.$compile(element)(itemScope);
return element;
}
public autocompleteWatchers(query) { public autocompleteWatchers(query) {
if (!query) { if (!query) {
return []; return [];
@ -103,7 +144,8 @@ export class WatchersPanelController {
}, },
{ {
caching: {enabled: false} caching: {enabled: false}
}).then(collection => { }).then((collection:CollectionResource) => {
this.$scope.noResults = collection.count === 0;
deferred.resolve(collection.elements); deferred.resolve(collection.elements);
}).catch(() => deferred.reject()); }).catch(() => deferred.reject());

@ -11,20 +11,12 @@
<wp-watcher watcher="watcher" <wp-watcher watcher="watcher"
ng-repeat="watcher in vm.watching track by watcher.id"></wp-watcher> ng-repeat="watcher in vm.watching track by watcher.id"></wp-watcher>
</div> </div>
<div class="work-package--watchers-lookup" ng-if="vm.allowedToAdd"> <div class="work-package--watchers-lookup" ng-show="vm.allowedToAdd">
<form name="watcherForm" novalidate> <form name="watcherForm" novalidate>
<input type="text" <input type="text"
class="wp-watcher--autocomplete typeahead-autocomplete" class="wp-watcher--autocomplete ui-autocomplete--input"
ng-class="{ '-error': vm.autocompleteInput.length && noResults }" ng-class="{ '-error': noResults }"
ng-model="vm.autocompleteInput" placeholder="{{ ::vm.text.autocomplete.placeholder }}">
op-fullwidth-typeahead
typeahead-is-open="autocompleteIsOpen"
typeahead-on-select="vm.addWatcher($model)"
typeahead-no-results="noResults"
typeahead-wait-ms="100"
typeahead-template-url="/components/common/typeahead/users/typeahead-match.html"
placeholder="{{ ::vm.text.autocomplete.placeholder }}"
uib-typeahead="item as (item.name | htmlEscape) for item in vm.autocompleteWatchers($viewValue)">
</form> </form>
</div> </div>
</div> </div>

@ -30,6 +30,7 @@ import {wpDirectivesModule} from '../../../../angular-modules';
import {WorkPackageRelationsController} from "../../wp-relations.directive"; import {WorkPackageRelationsController} from "../../wp-relations.directive";
import {WorkPackageRelationsHierarchyController} from "../../wp-relations-hierarchy/wp-relations-hierarchy.directive"; import {WorkPackageRelationsHierarchyController} from "../../wp-relations-hierarchy/wp-relations-hierarchy.directive";
import {WorkPackageResourceInterface} from "../../../api/api-v3/hal-resources/work-package-resource.service"; import {WorkPackageResourceInterface} from "../../../api/api-v3/hal-resources/work-package-resource.service";
import {CollectionResource} from '../../../api/api-v3/hal-resources/collection-resource.service';
function wpRelationsAutocompleteDirective($q, PathHelper, $http, I18n) { function wpRelationsAutocompleteDirective($q, PathHelper, $http, I18n) {
return { return {
@ -50,17 +51,30 @@ function wpRelationsAutocompleteDirective($q, PathHelper, $http, I18n) {
scope.options = []; scope.options = [];
scope.relatedWps = []; scope.relatedWps = [];
scope.onSelect = (selected) => { jQuery('.wp-relations--autocomplete').autocomplete({
scope.selectedWpId = selected.id; delay: 250,
}; autoFocus: false, // Accessibility!
source: (request:{ term:string }, response:Function) => {
autocompleteWorkPackages(request.term).then((values) => {
response(values.map(wp => {
return { workPackage: wp, value: getIdentifier(wp) };
}));
});
},
select: (evt, ui:any) => {
scope.$evalAsync(() => {
scope.selectedWpId = ui.item.workPackage.id;
});
}
});
scope.getIdentifier = function(workPackage){ function getIdentifier(workPackage){
if (workPackage) { if (workPackage) {
return _.escape(`#${workPackage.id} - ${workPackage.subject}`); return _.escape(`#${workPackage.id} - ${workPackage.subject}`);
} }
}; };
scope.autocompleteWorkPackages = (query) => { function autocompleteWorkPackages(query) {
if (!query) { if (!query) {
return []; return [];
} }
@ -68,15 +82,15 @@ function wpRelationsAutocompleteDirective($q, PathHelper, $http, I18n) {
const deferred = $q.defer(); const deferred = $q.defer();
scope.loadingPromise = deferred.promise; scope.loadingPromise = deferred.promise;
scope.workPackage scope.workPackage.available_relation_candidates.$link.$fetch({
.available_relation_candidates.$link.$fetch({
query: query, query: query,
type: scope.filterCandidatesFor type: scope.filterCandidatesFor
}, { }, {
'caching': { 'caching': {
enabled: false enabled: false
} }
}).then(collection => { }).then((collection:CollectionResource) => {
scope.noResults = collection.count === 0;
deferred.resolve(collection.elements); deferred.resolve(collection.elements);
}).catch(() => deferred.reject()); }).catch(() => deferred.reject());

@ -1,15 +1,6 @@
<form name="add_relation_form" class="form"> <form name="add_relation_form" class="form">
<input type="text" <input type="text"
class="wp-relations--autocomplete typeahead-autocomplete" class="wp-relations--autocomplete ui-autocomplete--input"
ng-class="{ '-error': noResults }" ng-class="{ '-error': noResults }"
ng-model="selectedWp" placeholder="{{ ::text.placeholder }}">
op-fullwidth-typeahead
typeahead-is-open="autocompleteIsOpen"
typeahead-append-to-body="true"
typeahead-on-select="onSelect($model)"
typeahead-no-results="noResults"
typeahead-wait-ms="100"
typeahead-input-formatter="getIdentifier($model)"
placeholder="{{ ::text.placeholder }}"
uib-typeahead="item as getIdentifier(item) for item in autocompleteWorkPackages($viewValue)">
</form> </form>

@ -9,6 +9,7 @@
"chai-as-promised": "github:DefinitelyTyped/DefinitelyTyped/chai-as-promised/chai-as-promised.d.ts#86bb15f6b9a3515bd9b3ca61f2a670533ac6b908", "chai-as-promised": "github:DefinitelyTyped/DefinitelyTyped/chai-as-promised/chai-as-promised.d.ts#86bb15f6b9a3515bd9b3ca61f2a670533ac6b908",
"es6-shim": "registry:dt/es6-shim#0.31.2+20160726072212", "es6-shim": "registry:dt/es6-shim#0.31.2+20160726072212",
"jquery": "github:DefinitelyTyped/DefinitelyTyped/jquery/jquery.d.ts#4ec3c5bf291bc02f49eb30c1077340b235165c67", "jquery": "github:DefinitelyTyped/DefinitelyTyped/jquery/jquery.d.ts#4ec3c5bf291bc02f49eb30c1077340b235165c67",
"jqueryui": "registry:dt/jqueryui#1.11.0+20161214061125",
"lodash": "github:DefinitelyTyped/DefinitelyTyped/lodash/lodash.d.ts#627b6c158b73494c803f36fc2fe00ad75faa8fde", "lodash": "github:DefinitelyTyped/DefinitelyTyped/lodash/lodash.d.ts#627b6c158b73494c803f36fc2fe00ad75faa8fde",
"mocha": "github:DefinitelyTyped/DefinitelyTyped/mocha/mocha.d.ts#86bb15f6b9a3515bd9b3ca61f2a670533ac6b908", "mocha": "github:DefinitelyTyped/DefinitelyTyped/mocha/mocha.d.ts#86bb15f6b9a3515bd9b3ca61f2a670533ac6b908",
"moment": "github:DefinitelyTyped/DefinitelyTyped/moment/moment.d.ts#bcd5761826eb567876c197ccc6a87c4d05731054", "moment": "github:DefinitelyTyped/DefinitelyTyped/moment/moment.d.ts#bcd5761826eb567876c197ccc6a87c4d05731054",

File diff suppressed because it is too large Load Diff

@ -0,0 +1,8 @@
{
"resolution": "main",
"tree": {
"src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/ead8376ca80553332af7872f9fe723c6fbb4e412/jqueryui/index.d.ts",
"raw": "registry:dt/jqueryui#1.11.0+20161214061125",
"typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/ead8376ca80553332af7872f9fe723c6fbb4e412/jqueryui/index.d.ts"
}
}

@ -6,6 +6,7 @@
/// <reference path="globals/chai/index.d.ts" /> /// <reference path="globals/chai/index.d.ts" />
/// <reference path="globals/es6-shim/index.d.ts" /> /// <reference path="globals/es6-shim/index.d.ts" />
/// <reference path="globals/jquery/index.d.ts" /> /// <reference path="globals/jquery/index.d.ts" />
/// <reference path="globals/jqueryui/index.d.ts" />
/// <reference path="globals/lodash/index.d.ts" /> /// <reference path="globals/lodash/index.d.ts" />
/// <reference path="globals/mocha/index.d.ts" /> /// <reference path="globals/mocha/index.d.ts" />
/// <reference path="globals/moment-node/index.d.ts" /> /// <reference path="globals/moment-node/index.d.ts" />

@ -26,20 +26,20 @@
# See doc/COPYRIGHT.rdoc for more details. # See doc/COPYRIGHT.rdoc for more details.
#++ #++
shared_context 'typeahead helpers' do shared_context 'ui-autocomplete helpers' do
def search_typeahead(element, query:) def search_autocomplete(element, query:)
# Open the element # Open the element
element.click element.click
# Insert the text to find # Insert the text to find
element.set(query) element.set(query)
## ##
# Find the dropdown by reference # Find the open dropdown
element['aria-owns'] page.find('.ui-autocomplete', visible: true)
end end
def select_typeahead(element, query:, select_text: nil) def select_autocomplete(element, query:, select_text: nil)
target_dropdown = search_typeahead(element, query: query) target_dropdown = search_autocomplete(element, query: query)
## ##
# If a specific select_text is given, use that to locate the match, # If a specific select_text is given, use that to locate the match,
@ -47,6 +47,6 @@ shared_context 'typeahead helpers' do
text = select_text.presence || query text = select_text.presence || query
# click the element to select it # click the element to select it
page.find("##{target_dropdown} .uib-typeahead-match", text: text).click target_dropdown.find('.ui-menu-item', text: text).click
end end
end end

@ -1,7 +1,7 @@
require 'spec_helper' require 'spec_helper'
describe 'Work package relations tab', js: true, selenium: true do describe 'Work package relations tab', js: true, selenium: true do
include_context 'typeahead helpers' include_context 'ui-autocomplete helpers'
let(:user) { FactoryGirl.create :admin } let(:user) { FactoryGirl.create :admin }
@ -30,8 +30,8 @@ describe 'Work package relations tab', js: true, selenium: true do
container = find(container) container = find(container)
# Enter the query and select the child # Enter the query and select the child
typeahead = container.find(".wp-relations--autocomplete") autocomplete = container.find(".wp-relations--autocomplete")
select_typeahead(typeahead, query: query, select_text: expected_text) select_autocomplete(autocomplete, query: query, select_text: expected_text)
container.find('.wp-create-relation--save').click container.find('.wp-create-relation--save').click
@ -53,8 +53,8 @@ describe 'Work package relations tab', js: true, selenium: true do
select relation_label, from: 'relation-type--select' select relation_label, from: 'relation-type--select'
# Enter the query and select the child # Enter the query and select the child
typeahead = container.find(".wp-relations--autocomplete") autocomplete = container.find(".wp-relations--autocomplete")
select_typeahead(typeahead, query: wp.subject, select_text: wp.subject) select_autocomplete(autocomplete, query: wp.subject, select_text: wp.subject)
container.find('.wp-create-relation--save').click container.find('.wp-create-relation--save').click

@ -49,7 +49,7 @@ describe 'Watcher tab', js: true, selenium: true do
end end
shared_examples 'watchers tab' do shared_examples 'watchers tab' do
include_context 'typeahead helpers' include_context 'ui-autocomplete helpers'
before do before do
login_as(user) login_as(user)
@ -59,8 +59,8 @@ describe 'Watcher tab', js: true, selenium: true do
it 'modifying the watcher list modifies the watch button' do it 'modifying the watcher list modifies the watch button' do
# Add user as watcher # Add user as watcher
typeahead = find('.wp-watcher--autocomplete') autocomplete = find('.wp-watcher--autocomplete')
select_typeahead(typeahead, query: user.firstname, select_text: user.name) select_autocomplete(autocomplete, query: user.firstname, select_text: user.name)
# Expect the addition of the user to toggle WP watch button # Expect the addition of the user to toggle WP watch button
expect(page).to have_selector('.work-package--watcher-name', count: 1, text: user.name) expect(page).to have_selector('.work-package--watcher-name', count: 1, text: user.name)
@ -84,11 +84,11 @@ describe 'Watcher tab', js: true, selenium: true do
} }
it 'escapes the user name' do it 'escapes the user name' do
typeahead = find('.wp-watcher--autocomplete') autocomplete = find('.wp-watcher--autocomplete')
target_dropdown = search_typeahead(typeahead, query: 'foo') target_dropdown = search_autocomplete(autocomplete, query: 'foo')
expect(page).to have_selector("##{target_dropdown} .uib-typeahead-match", text: html_user.firstname) expect(target_dropdown).to have_selector(".ui-menu-item", text: html_user.firstname)
expect(page).to have_no_selector("##{target_dropdown} .uib-typeahead-match em") expect(target_dropdown).to have_no_selector(".ui-menu-item em")
end end
end end

Loading…
Cancel
Save