diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js new file mode 100644 index 0000000000000000000000000000000000000000..8e9a97fe207bae0316663ac845d2a48a0994e63c --- /dev/null +++ b/app/assets/javascripts/fly_out_nav.js @@ -0,0 +1,51 @@ +/* global bp */ +import './breakpoints'; + +export const canShowSubItems = () => bp.getBreakpointSize() === 'md' || bp.getBreakpointSize() === 'lg'; + +export const calculateTop = (boundingRect, outerHeight) => { + const windowHeight = window.innerHeight; + const bottomOverflow = windowHeight - (boundingRect.top + outerHeight); + + return bottomOverflow < 0 ? (boundingRect.top - outerHeight) + boundingRect.height : + boundingRect.top; +}; + +export const showSubLevelItems = (el) => { + const subItems = el.querySelector('.sidebar-sub-level-items'); + + if (!subItems || !canShowSubItems()) return; + + subItems.style.display = 'block'; + el.classList.add('is-over'); + + const boundingRect = el.getBoundingClientRect(); + const top = calculateTop(boundingRect, subItems.offsetHeight); + const isAbove = top < boundingRect.top; + + subItems.style.transform = `translate3d(0, ${Math.floor(top)}px, 0)`; + + if (isAbove) { + subItems.classList.add('is-above'); + } +}; + +export const hideSubLevelItems = (el) => { + const subItems = el.querySelector('.sidebar-sub-level-items'); + + if (!subItems || !canShowSubItems()) return; + + el.classList.remove('is-over'); + subItems.style.display = 'none'; + subItems.classList.remove('is-above'); +}; + +export default () => { + const items = [...document.querySelectorAll('.sidebar-top-level-items > li:not(.active)')] + .filter(el => el.querySelector('.sidebar-sub-level-items')); + + items.forEach((el) => { + el.addEventListener('mouseenter', e => showSubLevelItems(e.currentTarget)); + el.addEventListener('mouseleave', e => hideSubLevelItems(e.currentTarget)); + }); +}; diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 6186ffe20b3250b8e5a9c0187782728e7c849b40..5c1ba416a0383770f78e26f7ba2ca70cf42fe18e 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import NewNavSidebar from './new_sidebar'; +import initFlyOutNav from './fly_out_nav'; (function() { var hideEndFade; @@ -58,6 +59,8 @@ import NewNavSidebar from './new_sidebar'; if (Cookies.get('new_nav') === 'true') { const newNavSidebar = new NewNavSidebar(); newNavSidebar.bindEvents(); + + initFlyOutNav(); } $(window).on('scroll', _.throttle(applyScrollNavClass, 100)); diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss index 2ed6dfbe7c2f09cbd0d2d20b06eb030fe7e2e4cb..3d202183c82c15ffe05afeec596e2ba412f8cdb5 100644 --- a/app/assets/stylesheets/new_sidebar.scss +++ b/app/assets/stylesheets/new_sidebar.scss @@ -220,6 +220,82 @@ $new-sidebar-width: 220px; .sidebar-top-level-items { > li { + > a { + @media (min-width: $screen-sm-min) { + margin-right: 2px; + } + + &:hover { + color: $gl-text-color; + } + } + + &:not(.active) { + > a { + margin-left: 1px; + margin-right: 3px; + } + + .sidebar-sub-level-items { + @media (min-width: $screen-sm-min) { + position: fixed; + top: 0; + left: 220px; + width: 150px; + margin-top: -1px; + padding: 8px 1px; + background-color: $white-light; + box-shadow: 2px 1px 3px $dropdown-shadow-color; + border: 1px solid $gray-darker; + border-left: 0; + border-radius: 0 3px 3px 0; + + &::before { + content: ""; + position: absolute; + top: -30px; + bottom: -30px; + left: 0; + right: -30px; + z-index: -1; + } + + &::after { + content: ""; + position: absolute; + top: 44px; + left: -30px; + right: 35px; + bottom: 0; + height: 100%; + max-height: 150px; + z-index: -1; + transform: skew(33deg); + } + + &.is-above { + margin-top: 1px; + + &::after { + top: auto; + bottom: 44px; + transform: skew(-30deg); + } + } + + a { + padding: 8px 16px; + color: $gl-text-color; + + &:hover, + &:focus { + background-color: $gray-darker; + } + } + } + } + } + .badge { background-color: $inactive-badge-background; color: $inactive-color; @@ -228,6 +304,10 @@ $new-sidebar-width: 220px; &.active { background: $active-background; + > a { + margin-left: 4px; + } + .badge { color: $active-color; font-weight: 600; @@ -238,18 +318,10 @@ $new-sidebar-width: 220px; } } - > a:hover { - background-color: $hover-background; - color: $hover-color; - - svg { - fill: $hover-color; - } - - .badge { - background-color: $indigo-500; - color: $hover-color; - } + &:not(.active):hover > a, + > a:hover, + &.is-over > a { + background-color: $white-light; } } } diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml index 1a2c932558323218cb8a8485559acde0e3b5f9c9..9f1cb248c4e582b1e68f5dfc275888c71f8762a5 100644 --- a/app/views/layouts/nav/_new_project_sidebar.html.haml +++ b/app/views/layouts/nav/_new_project_sidebar.html.haml @@ -91,34 +91,25 @@ %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count) %ul.sidebar-sub-level-items - - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) - = nav_link(controller: :issues) do - = link_to project_issues_path(@project), title: 'Issues' do - %span - List - - = nav_link(controller: :boards) do - = link_to project_boards_path(@project), title: 'Board' do - %span - Board + = nav_link(controller: :issues) do + = link_to project_issues_path(@project), title: 'Issues' do + %span + List - - if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests) - = nav_link(controller: :merge_requests) do - = link_to project_merge_requests_path(@project), title: 'Merge Requests' do - %span - Merge Requests + = nav_link(controller: :boards) do + = link_to project_boards_path(@project), title: 'Board' do + %span + Board - - if project_nav_tab? :labels - = nav_link(controller: :labels) do - = link_to project_labels_path(@project), title: 'Labels' do - %span - Labels + = nav_link(controller: :labels) do + = link_to project_labels_path(@project), title: 'Labels' do + %span + Labels - - if project_nav_tab? :milestones - = nav_link(controller: :milestones) do - = link_to project_milestones_path(@project), title: 'Milestones' do - %span - Milestones + = nav_link(controller: :milestones) do + = link_to project_milestones_path(@project), title: 'Milestones' do + %span + Milestones - if project_nav_tab? :merge_requests = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do @@ -195,7 +186,7 @@ %ul.sidebar-sub-level-items - can_edit = can?(current_user, :admin_project, @project) - if can_edit - = nav_link(controller: :projects) do + = nav_link(path: %w[projects#edit]) do = link_to edit_project_path(@project), title: 'General' do %span General diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ab74f3e00ec89cec113be2f116ddb0a6865a4d32 --- /dev/null +++ b/spec/javascripts/fly_out_nav_spec.js @@ -0,0 +1,180 @@ +/* global bp */ +import { + calculateTop, + hideSubLevelItems, + showSubLevelItems, + canShowSubItems, +} from '~/fly_out_nav'; + +describe('Fly out sidebar navigation', () => { + let el; + let breakpointSize = 'lg'; + + beforeEach(() => { + el = document.createElement('div'); + el.style.position = 'relative'; + document.body.appendChild(el); + + spyOn(bp, 'getBreakpointSize').and.callFake(() => breakpointSize); + }); + + afterEach(() => { + el.remove(); + breakpointSize = 'lg'; + }); + + describe('calculateTop', () => { + it('returns boundingRect top', () => { + const boundingRect = { + top: 100, + height: 100, + }; + + expect( + calculateTop(boundingRect, 100), + ).toBe(100); + }); + + it('returns boundingRect - bottomOverflow', () => { + const boundingRect = { + top: window.innerHeight - 50, + height: 100, + }; + + expect( + calculateTop(boundingRect, 100), + ).toBe(window.innerHeight - 50); + }); + }); + + describe('hideSubLevelItems', () => { + beforeEach(() => { + el.innerHTML = '<div class="sidebar-sub-level-items"></div>'; + }); + + it('hides subitems', () => { + hideSubLevelItems(el); + + expect( + el.querySelector('.sidebar-sub-level-items').style.display, + ).toBe('none'); + }); + + it('does not hude subitems on mobile', () => { + breakpointSize = 'sm'; + + hideSubLevelItems(el); + + expect( + el.querySelector('.sidebar-sub-level-items').style.display, + ).not.toBe('none'); + }); + + it('removes is-over class', () => { + spyOn(el.classList, 'remove'); + + hideSubLevelItems(el); + + expect( + el.classList.remove, + ).toHaveBeenCalledWith('is-over'); + }); + + it('removes is-above class from sub-items', () => { + const subItems = el.querySelector('.sidebar-sub-level-items'); + + spyOn(subItems.classList, 'remove'); + + hideSubLevelItems(el); + + expect( + subItems.classList.remove, + ).toHaveBeenCalledWith('is-above'); + }); + + it('does nothing if el has no sub-items', () => { + el.innerHTML = ''; + + spyOn(el.classList, 'remove'); + + hideSubLevelItems(el); + + expect( + el.classList.remove, + ).not.toHaveBeenCalledWith(); + }); + }); + + describe('showSubLevelItems', () => { + beforeEach(() => { + el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>'; + }); + + it('adds is-over class to el', () => { + spyOn(el.classList, 'add'); + + showSubLevelItems(el); + + expect( + el.classList.add, + ).toHaveBeenCalledWith('is-over'); + }); + + it('does not show sub-items on mobile', () => { + breakpointSize = 'sm'; + + showSubLevelItems(el); + + expect( + el.querySelector('.sidebar-sub-level-items').style.display, + ).not.toBe('block'); + }); + + it('does not shows sub-items', () => { + showSubLevelItems(el); + + expect( + el.querySelector('.sidebar-sub-level-items').style.display, + ).toBe('block'); + }); + + it('sets transform of sub-items', () => { + const subItems = el.querySelector('.sidebar-sub-level-items'); + showSubLevelItems(el); + + expect( + subItems.style.transform, + ).toBe(`translate3d(0px, ${Math.floor(el.getBoundingClientRect().top)}px, 0px)`); + }); + + it('sets is-above when element is above', () => { + const subItems = el.querySelector('.sidebar-sub-level-items'); + subItems.style.height = `${window.innerHeight + el.offsetHeight}px`; + el.style.top = `${window.innerHeight - el.offsetHeight}px`; + + spyOn(subItems.classList, 'add'); + + showSubLevelItems(el); + + expect( + subItems.classList.add, + ).toHaveBeenCalledWith('is-above'); + }); + }); + + describe('canShowSubItems', () => { + it('returns true if on desktop size', () => { + expect( + canShowSubItems(), + ).toBeTruthy(); + }); + + it('returns false if on mobile size', () => { + breakpointSize = 'sm'; + + expect( + canShowSubItems(), + ).toBeFalsy(); + }); + }); +});