From 6f92526ad5f34bd77cc80c1009c8fb43ed4f770d Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Tue, 4 Oct 2016 23:52:10 +0100 Subject: Added scroll into view from item selection --- app/assets/javascripts/dropdown/gl_dropdown.js.es6 | 59 ++++++++++++++++++---- .../issuable_filter/issuable_filter.js.es6 | 45 ++++++++++------- app/assets/stylesheets/framework/dropdowns.scss | 34 +++++++------ app/views/shared/issuable/_filter.html.haml | 2 +- .../shared/issuable/_issuable_filter.html.haml | 45 ----------------- .../issuable/_issuable_filter_input.html.haml | 46 +++++++++++++++++ .../shared/vue_templates/_gl_dropdown.html.haml | 22 ++++---- 7 files changed, 155 insertions(+), 98 deletions(-) delete mode 100644 app/views/shared/issuable/_issuable_filter.html.haml create mode 100644 app/views/shared/issuable/_issuable_filter_input.html.haml diff --git a/app/assets/javascripts/dropdown/gl_dropdown.js.es6 b/app/assets/javascripts/dropdown/gl_dropdown.js.es6 index d501577bfa8..b6d86689e04 100644 --- a/app/assets/javascripts/dropdown/gl_dropdown.js.es6 +++ b/app/assets/javascripts/dropdown/gl_dropdown.js.es6 @@ -49,6 +49,7 @@ $($('[slot="header-item"]', this.$el).get().reverse()) .each(this.createItemFromSlot); this.setGlobalIndexes(); + this.$scrollableContainer = $('.scrollable-container', this.$el); if (!this.deferRequest) this.requestData(); }, methods: { @@ -188,36 +189,74 @@ if (this.deferRequest) this.requestData(); }, /** - * Hides the dropdown and resets its selected item. + * Hides the dropdown and resets its selected item and scroll position. */ closeDropdown() { - this.selectedItemIndex = -1; this.showDropdown = false; + this.selectedItemIndex = -1; + this.$scrollableContainer.scrollTop(0); }, /** * Increments the selectedItemIndex by one if there is a - * next item to select. + * next item to select. Lastly, scrolls item into view if needed. */ selectNextItem() { - if(this.selectedItemIndex < this.items.length - 1) this.selectedItemIndex++; + if(this.selectedItemIndex < this.items.length - 1) { + this.selectedItemIndex++; + this.scrollSelectedItemIntoView(); + } }, /** * Decrement the selectedItemIndex by one if there is a - * previous item to select. + * previous item to select. Lastly, scrolls item into view if needed. */ selectPrevItem() { - if(this.selectedItemIndex > -1) this.selectedItemIndex--; + if(this.selectedItemIndex > -1) { + this.selectedItemIndex--; + this.scrollSelectedItemIntoView(); + } }, /** * Simulates the clicking of an item by setting the model to the items * value. Lastly, it closes the dropdown. * TODO: Implement ability to click with mouse, this does not currently * work unless invoked directly from instance controller. + * This method may be able to be changed a lot depending on what happens */ clickCurrentItem() { - selectedItemKey = Object.keys(this.items)[this.selectedItemIndex]; - this.fieldModel = this.items[selectedItemKey][this.valueKey]; + this.fieldModel = this.items[this.selectedItemIndex][this.valueKey]; this.closeDropdown(); + }, + /** + * Scrolls the currently selected item into view. It queries the set of + * selectable items and selects the currently selected one using the + * selected item index. If the currently selected item is out of the + * bounds of the dropdown it will scroll to keep the item fully in view. + */ + scrollSelectedItemIntoView() { + const $item = $('.item', this.$scrollableContainer) + .eq(this.selectedItemIndex); + if (!$item.length) return; + + let newScrollTop; + if (this.selectedItemIndex === -1) { + newScrollTop = 0; + } else { + const scrollTop = this.$scrollableContainer.scrollTop(); + const containerTop = this.$scrollableContainer.offset().top; + const containerBottom = containerTop + this.$scrollableContainer.height(); + const itemHeight = $item.outerHeight(); + const itemTop = $item.offset().top; + const itemBottom = itemTop + itemHeight; + + if (itemBottom > containerBottom) { + newScrollTop = scrollTop + itemHeight; + } else if (itemTop < containerTop) { + newScrollTop = scrollTop - itemHeight; + } + } + + this.$scrollableContainer.scrollTop(newScrollTop); } }, computed: { @@ -234,7 +273,9 @@ * @return {Object} - An array of regular items. */ regularItems() { - return this.items.filter((item) => item.slotType === 'item' || !item.slotType); + return this.items.filter((item) => { + return item.slotType === 'item' || !item.slotType + }); }, /** * Filters an array of only footer items from the default item array. diff --git a/app/assets/javascripts/issuable_filter/issuable_filter.js.es6 b/app/assets/javascripts/issuable_filter/issuable_filter.js.es6 index 9cb4f90232f..aa1d1c83870 100644 --- a/app/assets/javascripts/issuable_filter/issuable_filter.js.es6 +++ b/app/assets/javascripts/issuable_filter/issuable_filter.js.es6 @@ -44,29 +44,27 @@ $(() => { * open dropdowns. * Lastly, initialise the field model observers. */ - openFilterDropdown() { - this.closeFilterDropdowns(); + openDefaultDropdown() { + this.closeDropdowns(); this.$refs[DEFAULT_DROPDOWN].openDropdown(); this.initFieldObservers(); }, /** * Closes all dropdowns and sets the current dropdown to default. */ - closeFilterDropdowns() { + closeDropdowns() { for (const dropdownReference in this.dropdown) { this.$refs[dropdownReference].closeDropdown(); } this.currentDropdown = DEFAULT_DROPDOWN; }, /** - * Handle a keyup event from the filter input. + * Handle a keydown event from the filter input. * On up: Select the previous item of the current dropdown. * On down: Select the next item of the current dropdown. - * On enter: Click the current item of the current dropdown. - * On esc: Close the dropdowns and open the default dropdown. - * @param {Event} event - A keyup event object from the filter input. + * @param {Event} event - A keydown event object from the filter input. */ - filterInputKeyup(event) { + filterInputKeydown(event) { const keycode = event.keyCode || event.which; switch (keycode) { case KEY.UP: @@ -75,11 +73,22 @@ $(() => { case KEY.DOWN: this.$refs[this.currentDropdown].selectNextItem(); break; + } + }, + /** + * Handle a keyup event from the filter input. + * On enter: Click the current item of the current dropdown. + * On esc: Close the dropdowns and open the default dropdown. + * @param {Event} event - A keyup event object from the filter input. + */ + filterInputKeyup(event) { + const keycode = event.keyCode || event.which; + switch (keycode) { case KEY.ENTER: this.$refs[this.currentDropdown].clickCurrentItem(); break; case KEY.ESC: - this.openFilterDropdown() + this.openDefaultDropdown() break; } }, @@ -91,6 +100,7 @@ $(() => { */ openSelectedDropdown(clickedDropdownItem) { if (!clickedDropdownItem) return; + this.closeDropdowns(); this.$refs[clickedDropdownItem].openDropdown(); this.currentDropdown = clickedDropdownItem; }, @@ -105,16 +115,17 @@ $(() => { addFilter(filterType, filterValue) { if (!filterValue) return; const { valueKey, titleKey } = this.$refs[this.currentDropdown]; - const filterItem = this.$refs[this.currentDropdown].items.find((item) => { - return item[valueKey] === filterValue; - }); + const filterItem = this.$refs[this.currentDropdown].items + .find((item) => { + return item[valueKey] === filterValue; + }); const filter = { type: filterType, value: filterValue, title: filterItem[titleKey] }; this.activeFilters.push(filter); - this.closeFilterDropdowns() + this.closeDropdowns() this.$refs[this.currentDropdown].openDropdown(); }, /** @@ -124,7 +135,7 @@ $(() => { clearFilters() { this.activeFilters = []; this.filterInput = ''; - this.closeFilterDropdowns(); + this.closeDropdowns(); }, /** * TODO: Implement submit method. @@ -154,12 +165,12 @@ $(() => { * @param {String} observerName - The unique name of the observer. * @param {Function} observable - A function returning an observable * entity. - * @param {Function} updateAction - A function to invoke when the + * @param {Function} action - A function to invoke when the * observable entity value updates. */ - registerFieldObserver(observerName, observable, updateAction) { + registerFieldObserver(observerName, observable, action) { if (this.modelObserver[observerName]) return; - this.modelObserver[observerName] = this.$watch(observable, updateAction); + this.modelObserver[observerName] = this.$watch(observable, action); } } }); diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 548a09240cf..59ecad9d58f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -611,6 +611,12 @@ border-radius: $border-radius-default; border: 1px solid $dropdown-2-border-color; min-width: 280px; + padding: 6px 0; + + .scrollable-container { + max-height: 225px; + overflow: scroll; + } .items, .header-items, @@ -618,11 +624,21 @@ list-style: none; margin: 0; padding: 0; - padding: 6px 0; } .header-items { - border-bottom: 1px solid $dropdown-2-divider-color; + position: relative; + + &:after { + border-bottom: 1px solid $dropdown-2-divider-color; + bottom: 0; + content: ''; + display: block; + left: 50%; + margin-left: -130px; + position: absolute; + width: 260px; + } } .footer-items { @@ -630,8 +646,6 @@ } .item, - .header-item, - .footer-item, [slot="item"], [slot="header-item"], [slot="footer-item"] { @@ -639,22 +653,13 @@ padding: $gl-padding; } - .item, - .header-item, - .footer-item { + .item { cursor: pointer; &.active, &:hover { background-color: $dropdown-2-active-item-background-color; color: $white-light; } - - &.last-header-item { - border-bottom: 1px solid $dropdown-2-divider-color; - } - &.first-footer-item { - border-top: 1px solid $dropdown-2-divider-color; - } } } @@ -662,7 +667,6 @@ display: block; font-size: 32px; width: 32px; - // height: 32px; margin: 32px auto; } } diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 4009fbd805f..f92133a6283 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -5,7 +5,7 @@ = check_box_tag "check_all_issues", nil, false, class: "check_all_issues left" - = render 'shared/issuable/issuable_filter' + = render 'shared/issuable/issuable_filter_input' - if @bulk_edit .issues_bulk_update.hide diff --git a/app/views/shared/issuable/_issuable_filter.html.haml b/app/views/shared/issuable/_issuable_filter.html.haml deleted file mode 100644 index 1753f6a1cd3..00000000000 --- a/app/views/shared/issuable/_issuable_filter.html.haml +++ /dev/null @@ -1,45 +0,0 @@ -- boards_page = controller.controller_name == 'boards' -- content_for :page_specific_javascripts do - = page_specific_javascript_tag('issuable_filter/issuable_filter.js') - -.issues-other-filters.js-issuable-filter - = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form form-inline', '@submit.prevent': 'submitFilters' do - = icon('filter', class: 'filter-icon') - .filter-badges - .filter-badge{ 'v-for': 'filter in activeFilters' } - {{ filter.type }}:{{ filter.title }} - = text_field_tag 'issuable-filter-input', nil, class: 'form-control issuable-filter-input', '@focus': 'openFilterDropdown', '@blur': 'closeFilterDropdowns', '@keyup.prevent': 'filterInputKeyup', 'v-model': 'filterInput' - = icon('close', class: 'clear-filters', '@click': 'clearFilters') - - .dropdown-filters - %gl-dropdown{ ':show-dropdown.sync': 'dropdown.filterDropdown.show', 'v-ref:filter-dropdown': 'true' } - %filter-dropdown-item{ slot: 'item', icon: 'search', 'title': 'Keep typing and press Enter', 'non-selectable': 'true' } - %filter-dropdown-item{ slot: 'item', value: 'authorDropdown', icon: 'pencil', 'title': 'author:', 'subtitle': '' } - %filter-dropdown-item{ slot: 'item', value: 'assigneeDropdown', icon: 'user', 'title': 'assignee:', 'subtitle': '' } - %filter-dropdown-item{ slot: 'item', value: 'milestoneDropdown', icon: 'clock-o', 'title': 'milestone:', 'subtitle': '' } - %filter-dropdown-item{ slot: 'item', value: 'labelDropdown', icon: 'tag', 'title': 'label:', 'subtitle': '