Commit 0fba9e97 authored by Mark Florian's avatar Mark Florian Committed by Miguel Rincon

Make milestone page tabs Pajamas-compliant

The `GlTabsBehavior` class adds interactivity to tabs created by the
`gl_tabs_nav` and `gl_tab_link_to` Rails helpers.

It can be used to replace Bootstrap tab implementations that cannot
easily be rewritten in Vue.

Addresses #230761.

Changelog: other
parent d4b666a0
import $ from 'jquery';
import createFlash from '~/flash';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { historyPushState } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs';
export default class Milestone {
constructor() {
this.tabsEl = document.querySelector('.js-milestone-tabs');
this.glTabs = new GlTabsBehavior(this.tabsEl);
this.loadedTabs = new WeakSet();
this.bindTabsSwitching();
this.loadInitialTab();
}
bindTabsSwitching() {
return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => {
const $target = $(e.target);
window.location.hash = $target.attr('href');
this.loadTab($target);
this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => {
const tab = event.target;
const { activeTabPanel } = event.detail;
historyPushState(tab.getAttribute('href'));
this.loadTab(tab, activeTabPanel);
});
}
loadInitialTab() {
const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`);
if ($target.length) {
$target.tab('show');
} else {
this.loadTab($('.js-milestone-tabs a.active'));
}
const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`);
this.glTabs.activateTab(tab || this.glTabs.activeTab);
}
// eslint-disable-next-line class-methods-use-this
loadTab($target) {
const endpoint = $target.data('endpoint');
const tabElId = $target.attr('href');
loadTab(tab, tabPanel) {
const { endpoint } = tab.dataset;
if (endpoint && !$target.hasClass('is-loaded')) {
if (endpoint && !this.loadedTabs.has(tab)) {
axios
.get(endpoint)
.then(({ data }) => {
$(tabElId).html(data.html);
$target.addClass('is-loaded');
// eslint-disable-next-line no-param-reassign
tabPanel.innerHTML = sanitize(data.html);
this.loadedTabs.add(tab);
})
.catch(() =>
createFlash({
......
export const ACTIVE_TAB_CLASSES = Object.freeze([
'active',
'gl-tab-nav-item-active',
'gl-tab-nav-item-active-indigo',
]);
export const ACTIVE_PANEL_CLASS = 'active';
export const KEY_CODE_LEFT = 'ArrowLeft';
export const KEY_CODE_UP = 'ArrowUp';
export const KEY_CODE_RIGHT = 'ArrowRight';
export const KEY_CODE_DOWN = 'ArrowDown';
export const ATTR_ARIA_CONTROLS = 'aria-controls';
export const ATTR_ARIA_LABELLEDBY = 'aria-labelledby';
export const ATTR_ARIA_SELECTED = 'aria-selected';
export const ATTR_ROLE = 'role';
export const ATTR_TABINDEX = 'tabindex';
export const TAB_SHOWN_EVENT = 'gl-tab-shown';
import { uniqueId } from 'lodash';
import {
ACTIVE_TAB_CLASSES,
ATTR_ROLE,
ATTR_ARIA_CONTROLS,
ATTR_TABINDEX,
ATTR_ARIA_SELECTED,
ATTR_ARIA_LABELLEDBY,
ACTIVE_PANEL_CLASS,
KEY_CODE_LEFT,
KEY_CODE_UP,
KEY_CODE_RIGHT,
KEY_CODE_DOWN,
TAB_SHOWN_EVENT,
} from './constants';
export { TAB_SHOWN_EVENT };
/**
* The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and
* `gl_tab_link_to` Rails helpers.
*
* Example using `href` references:
*
* ```haml
* = gl_tabs_nav({ class: 'js-my-tabs' }) do
* = gl_tab_link_to '#foo', item_active: true do
* = _('Foo')
* = gl_tab_link_to '#bar' do
* = _('Bar')
*
* .tab-content
* .tab-pane.active#foo
* .tab-pane#bar
* ```
*
* ```javascript
* import { GlTabsBehavior } from '~/tabs';
*
* const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs'));
* ```
*
* Example using `aria-controls` references:
*
* ```haml
* = gl_tabs_nav({ class: 'js-my-tabs' }) do
* = gl_tab_link_to '#', item_active: true, 'aria-controls': 'foo' do
* = _('Foo')
* = gl_tab_link_to '#', 'aria-controls': 'bar' do
* = _('Bar')
*
* .tab-content
* .tab-pane.active#foo
* .tab-pane#bar
* ```
*
* ```javascript
* import { GlTabsBehavior } from '~/tabs';
*
* const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs'));
* ```
*
* `GlTabsBehavior` can be used to replace Bootstrap tab implementations that cannot
* easily be rewritten in Vue.
*
* NOTE: Do *not* use `GlTabsBehavior` with markup generated by other means, as it may not
* work correctly.
*
* Tab panels must exist somewhere in the page for the tabs to control. Tab panels
* must:
* - be immediate children of a `.tab-content` element
* - have the `tab-pane` class
* - if the panel is active, have the `active` class
* - have a unique `id` attribute
*
* In order to associate tabs with panels, the tabs must reference their panel's
* `id` by having one of the following attributes:
* - `href`, e.g., `href="#the-panel-id"` (note the leading `#` in the value)
* - `aria-controls`, e.g., `aria-controls="the-panel-id"` (no leading `#`)
*
* Exactly one tab/panel must be active in the original markup.
*
* Call the `destroy` method on an instance to remove event listeners that were
* added during construction. Other DOM mutations (like ARIA attributes) are
* _not_ reverted.
*/
export class GlTabsBehavior {
/**
* Create a GlTabsBehavior instance.
*
* @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper.
*/
constructor(el) {
if (!el) {
throw new Error('Cannot instantiate GlTabsBehavior without an element');
}
this.destroyFns = [];
this.tabList = el;
this.tabs = this.getTabs();
this.activeTab = null;
this.setAccessibilityAttrs();
this.bindEvents();
}
setAccessibilityAttrs() {
this.tabList.setAttribute(ATTR_ROLE, 'tablist');
this.tabs.forEach((tab) => {
if (!tab.hasAttribute('id')) {
tab.setAttribute('id', uniqueId('gl_tab_nav__tab_'));
}
if (!this.activeTab && tab.classList.contains(ACTIVE_TAB_CLASSES[0])) {
this.activeTab = tab;
tab.setAttribute(ATTR_ARIA_SELECTED, 'true');
tab.removeAttribute(ATTR_TABINDEX);
} else {
tab.setAttribute(ATTR_ARIA_SELECTED, 'false');
tab.setAttribute(ATTR_TABINDEX, '-1');
}
tab.setAttribute(ATTR_ROLE, 'tab');
tab.closest('.nav-item').setAttribute(ATTR_ROLE, 'presentation');
const tabPanel = this.getPanelForTab(tab);
if (!tab.hasAttribute(ATTR_ARIA_CONTROLS)) {
tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id);
}
tabPanel.setAttribute(ATTR_ROLE, 'tabpanel');
tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id);
});
}
bindEvents() {
this.tabs.forEach((tab) => {
this.bindEvent(tab, 'click', (event) => {
event.preventDefault();
if (tab !== this.activeTab) {
this.activateTab(tab);
}
});
this.bindEvent(tab, 'keydown', (event) => {
const { code } = event;
if (code === KEY_CODE_UP || code === KEY_CODE_LEFT) {
event.preventDefault();
this.activatePreviousTab();
} else if (code === KEY_CODE_DOWN || code === KEY_CODE_RIGHT) {
event.preventDefault();
this.activateNextTab();
}
});
});
}
bindEvent(el, ...args) {
el.addEventListener(...args);
this.destroyFns.push(() => {
el.removeEventListener(...args);
});
}
activatePreviousTab() {
const currentTabIndex = this.tabs.indexOf(this.activeTab);
if (currentTabIndex <= 0) return;
const previousTab = this.tabs[currentTabIndex - 1];
this.activateTab(previousTab);
previousTab.focus();
}
activateNextTab() {
const currentTabIndex = this.tabs.indexOf(this.activeTab);
if (currentTabIndex >= this.tabs.length - 1) return;
const nextTab = this.tabs[currentTabIndex + 1];
this.activateTab(nextTab);
nextTab.focus();
}
getTabs() {
return Array.from(this.tabList.querySelectorAll('.gl-tab-nav-item'));
}
// eslint-disable-next-line class-methods-use-this
getPanelForTab(tab) {
const ariaControls = tab.getAttribute(ATTR_ARIA_CONTROLS);
if (ariaControls) {
return document.querySelector(`#${ariaControls}`);
}
return document.querySelector(tab.getAttribute('href'));
}
activateTab(tabToActivate) {
// Deactivate active tab first
this.activeTab.setAttribute(ATTR_ARIA_SELECTED, 'false');
this.activeTab.setAttribute(ATTR_TABINDEX, '-1');
this.activeTab.classList.remove(...ACTIVE_TAB_CLASSES);
const activePanel = this.getPanelForTab(this.activeTab);
activePanel.classList.remove(ACTIVE_PANEL_CLASS);
// Now activate the given tab/panel
tabToActivate.setAttribute(ATTR_ARIA_SELECTED, 'true');
tabToActivate.removeAttribute(ATTR_TABINDEX);
tabToActivate.classList.add(...ACTIVE_TAB_CLASSES);
const tabPanel = this.getPanelForTab(tabToActivate);
tabPanel.classList.add(ACTIVE_PANEL_CLASS);
this.activeTab = tabToActivate;
this.dispatchTabShown(tabToActivate, tabPanel);
}
// eslint-disable-next-line class-methods-use-this
dispatchTabShown(tab, activeTabPanel) {
const event = new CustomEvent(TAB_SHOWN_EVENT, {
bubbles: true,
detail: {
activeTabPanel,
},
});
tab.dispatchEvent(event);
}
destroy() {
this.destroyFns.forEach((destroy) => destroy());
}
}
......@@ -14,8 +14,7 @@ module TabHelper
gl_tabs_classes = %w[nav gl-tabs-nav]
html_options = html_options.merge(
class: [*html_options[:class], gl_tabs_classes].join(' '),
role: 'tablist'
class: [*html_options[:class], gl_tabs_classes].join(' ')
)
content = capture(&block) if block_given?
......@@ -54,7 +53,7 @@ module TabHelper
extra_tab_classes = html_options.delete(:tab_class)
tab_class = %w[nav-item].push(*extra_tab_classes)
content_tag(:li, class: tab_class, role: 'presentation') do
content_tag(:li, class: tab_class) do
if block_given?
link_to(options, html_options, &block)
else
......
......@@ -3,24 +3,20 @@
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs
%li.nav-item
= link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
= gl_tabs_nav({ class: %w[scrolling-tabs js-milestone-tabs] }) do
= gl_tab_link_to '#tab-issues', item_active: true, data: { endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
= _('Issues')
%span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size
= gl_tab_counter_badge milestone.issues_visible_to_user(current_user).size
- if milestone.merge_requests_enabled?
%li.nav-item
= link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
= gl_tab_link_to '#tab-merge-requests', data: { endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
= _('Merge requests')
%span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
%li.nav-item
= link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do
= gl_tab_counter_badge milestone.merge_requests_visible_to_user(current_user).size
= gl_tab_link_to '#tab-participants', data: { endpoint: milestone_tab_path(milestone, 'participants') } do
= _('Participants')
%span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count
%li.nav-item
= link_to '#tab-labels', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'labels') } do
= gl_tab_counter_badge milestone.issue_participants_visible_by_user(current_user).count
= gl_tab_link_to '#tab-labels', data: { endpoint: milestone_tab_path(milestone, 'labels') } do
= _('Labels')
%span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count
= gl_tab_counter_badge milestone.issue_labels_visible_by_user(current_user).count
.tab-content.milestone-content
.tab-pane.active#tab-issues
......
......@@ -2,10 +2,11 @@
require 'spec_helper'
RSpec.describe 'Project milestone' do
RSpec.describe 'Project milestone', :js do
let(:user) { create(:user) }
let(:project) { create(:project, name: 'test', namespace: user.namespace) }
let(:milestone) { create(:milestone, project: project) }
let(:active_tab_selector) { '[role="tab"][aria-selected="true"]' }
def toggle_sidebar
find('.milestone-sidebar .gutter-toggle').click
......@@ -31,8 +32,9 @@ RSpec.describe 'Project milestone' do
it 'shows issues tab' do
within('#content-body') do
expect(page).to have_link 'Issues', href: '#tab-issues'
expect(page).to have_selector '.nav-links li a.active', count: 1
expect(find('.nav-links li a.active')).to have_content 'Issues'
expect(page).to have_selector active_tab_selector, count: 1
expect(find(active_tab_selector)).to have_content 'Issues'
expect(page).to have_text('Unstarted Issues')
end
end
......@@ -49,6 +51,35 @@ RSpec.describe 'Project milestone' do
end
end
context 'when clicking on other tabs' do
using RSpec::Parameterized::TableSyntax
where(:tab_text, :href, :panel_content) do
'Merge requests' | '#tab-merge-requests' | 'Work in progress'
'Participants' | '#tab-participants' | nil
'Labels' | '#tab-labels' | nil
end
with_them do
before do
visit project_milestone_path(project, milestone)
click_link(tab_text, href: href)
end
it 'shows the merge requests tab and panel' do
within('#content-body') do
expect(find(active_tab_selector)).to have_content tab_text
expect(find(href)).to be_visible
expect(page).to have_text(panel_content) if panel_content
end
end
it 'sets the location hash' do
expect(current_url).to end_with(href)
end
end
end
context 'when project has disabled issues' do
before do
create(:issue, project: project, milestone: milestone)
......@@ -59,7 +90,7 @@ RSpec.describe 'Project milestone' do
it 'does not show any issues under the issues tab' do
within('#content-body') do
expect(find('.nav-links li a.active')).to have_content 'Issues'
expect(find(active_tab_selector)).to have_content 'Issues'
expect(page).not_to have_selector '.issuable-row'
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'GlTabsBehavior', '(JavaScript fixtures)', type: :helper do
include JavaScriptFixturesHelpers
include TabHelper
let(:response) { @tabs }
it 'tabs/tabs.html' do
tabs = gl_tabs_nav({ data: { testid: 'tabs' } }) do
gl_tab_link_to('Foo', '#foo', item_active: true, data: { testid: 'foo-tab' }) +
gl_tab_link_to('Bar', '#bar', item_active: false, data: { testid: 'bar-tab' }) +
gl_tab_link_to('Qux', '#qux', item_active: false, data: { testid: 'qux-tab' })
end
panels = content_tag(:div, class: 'tab-content') do
content_tag(:div, 'Foo', { id: 'foo', class: 'tab-pane active', data: { testid: 'foo-panel' } }) +
content_tag(:div, 'Bar', { id: 'bar', class: 'tab-pane', data: { testid: 'bar-panel' } }) +
content_tag(:div, 'Qux', { id: 'qux', class: 'tab-pane', data: { testid: 'qux-panel' } })
end
@tabs = tabs + panels
end
end
import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs';
import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants';
import { getFixture, setHTMLFixture } from 'helpers/fixtures';
const tabsFixture = getFixture('tabs/tabs.html');
describe('GlTabsBehavior', () => {
let glTabs;
let tabShownEventSpy;
const findByTestId = (testId) => document.querySelector(`[data-testid="${testId}"]`);
const findTab = (name) => findByTestId(`${name}-tab`);
const findPanel = (name) => findByTestId(`${name}-panel`);
const getAttributes = (element) =>
Array.from(element.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {});
const expectActiveTabAndPanel = (name) => {
const tab = findTab(name);
const panel = findPanel(name);
expect(glTabs.activeTab).toBe(tab);
expect(getAttributes(tab)).toMatchObject({
'aria-controls': panel.id,
'aria-selected': 'true',
role: 'tab',
id: expect.any(String),
});
ACTIVE_TAB_CLASSES.forEach((klass) => {
expect(tab.classList.contains(klass)).toBe(true);
});
expect(getAttributes(panel)).toMatchObject({
'aria-labelledby': tab.id,
role: 'tabpanel',
});
expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(true);
};
const expectInactiveTabAndPanel = (name) => {
const tab = findTab(name);
const panel = findPanel(name);
expect(glTabs.activeTab).not.toBe(tab);
expect(getAttributes(tab)).toMatchObject({
'aria-controls': panel.id,
'aria-selected': 'false',
role: 'tab',
tabindex: '-1',
id: expect.any(String),
});
ACTIVE_TAB_CLASSES.forEach((klass) => {
expect(tab.classList.contains(klass)).toBe(false);
});
expect(getAttributes(panel)).toMatchObject({
'aria-labelledby': tab.id,
role: 'tabpanel',
});
expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(false);
};
const expectGlTabShownEvent = (name) => {
expect(tabShownEventSpy).toHaveBeenCalledTimes(1);
const [event] = tabShownEventSpy.mock.calls[0];
expect(event.target).toBe(findTab(name));
expect(event.detail).toEqual({
activeTabPanel: findPanel(name),
});
};
const triggerKeyDown = (code, element) => {
const event = new KeyboardEvent('keydown', { code });
element.dispatchEvent(event);
};
it('throws when instantiated without an element', () => {
expect(() => new GlTabsBehavior()).toThrow('Cannot instantiate');
});
describe('when given an element', () => {
afterEach(() => {
glTabs.destroy();
});
beforeEach(() => {
setHTMLFixture(tabsFixture);
const tabsEl = findByTestId('tabs');
tabShownEventSpy = jest.fn();
tabsEl.addEventListener(TAB_SHOWN_EVENT, tabShownEventSpy);
glTabs = new GlTabsBehavior(tabsEl);
});
it('instantiates', () => {
expect(glTabs).toEqual(expect.any(GlTabsBehavior));
});
it('sets the active tab', () => {
expectActiveTabAndPanel('foo');
});
it(`does not fire an initial ${TAB_SHOWN_EVENT} event`, () => {
expect(tabShownEventSpy).not.toHaveBeenCalled();
});
describe('clicking on an inactive tab', () => {
beforeEach(() => {
findTab('bar').click();
});
it('changes the active tab', () => {
expectActiveTabAndPanel('bar');
});
it('deactivates the previously active tab', () => {
expectInactiveTabAndPanel('foo');
});
it(`dispatches a ${TAB_SHOWN_EVENT} event`, () => {
expectGlTabShownEvent('bar');
});
});
describe('clicking on the active tab', () => {
beforeEach(() => {
findTab('foo').click();
});
it('does nothing', () => {
expectActiveTabAndPanel('foo');
expect(tabShownEventSpy).not.toHaveBeenCalled();
});
});
describe('keyboard navigation', () => {
it.each(['ArrowRight', 'ArrowDown'])('pressing %s moves to next tab', (code) => {
expectActiveTabAndPanel('foo');
triggerKeyDown(code, glTabs.activeTab);
expectActiveTabAndPanel('bar');
expectInactiveTabAndPanel('foo');
expectGlTabShownEvent('bar');
tabShownEventSpy.mockClear();
triggerKeyDown(code, glTabs.activeTab);
expectActiveTabAndPanel('qux');
expectInactiveTabAndPanel('bar');
expectGlTabShownEvent('qux');
tabShownEventSpy.mockClear();
// We're now on the last tab, so the active tab should not change
triggerKeyDown(code, glTabs.activeTab);
expectActiveTabAndPanel('qux');
expect(tabShownEventSpy).not.toHaveBeenCalled();
});
it.each(['ArrowLeft', 'ArrowUp'])('pressing %s moves to previous tab', (code) => {
// First, make the last tab active
findTab('qux').click();
tabShownEventSpy.mockClear();
// Now start moving backwards
expectActiveTabAndPanel('qux');
triggerKeyDown(code, glTabs.activeTab);
expectActiveTabAndPanel('bar');
expectInactiveTabAndPanel('qux');
expectGlTabShownEvent('bar');
tabShownEventSpy.mockClear();
triggerKeyDown(code, glTabs.activeTab);
expectActiveTabAndPanel('foo');
expectInactiveTabAndPanel('bar');
expectGlTabShownEvent('foo');
tabShownEventSpy.mockClear();
// We're now on the first tab, so the active tab should not change
triggerKeyDown(code, glTabs.activeTab);
expectActiveTabAndPanel('foo');
expect(tabShownEventSpy).not.toHaveBeenCalled();
});
});
describe('destroying', () => {
beforeEach(() => {
glTabs.destroy();
});
it('removes interactivity', () => {
const inactiveTab = findTab('bar');
// clicks do nothing
inactiveTab.click();
expectActiveTabAndPanel('foo');
expect(tabShownEventSpy).not.toHaveBeenCalled();
// keydown events do nothing
triggerKeyDown('ArrowDown', inactiveTab);
expectActiveTabAndPanel('foo');
expect(tabShownEventSpy).not.toHaveBeenCalled();
});
});
describe('activateTab method', () => {
it.each`
tabState | name
${'active'} | ${'foo'}
${'inactive'} | ${'bar'}
`('can programmatically activate an $tabState tab', ({ name }) => {
glTabs.activateTab(findTab(name));
expectActiveTabAndPanel(name);
expectGlTabShownEvent(name, 'foo');
});
});
});
describe('using aria-controls instead of href to link tabs to panels', () => {
beforeEach(() => {
setHTMLFixture(tabsFixture);
const tabsEl = findByTestId('tabs');
['foo', 'bar', 'qux'].forEach((name) => {
const tab = findTab(name);
const panel = findPanel(name);
tab.setAttribute('href', '#');
tab.setAttribute('aria-controls', panel.id);
});
glTabs = new GlTabsBehavior(tabsEl);
});
it('connects the panels to their tabs correctly', () => {
findTab('bar').click();
expectActiveTabAndPanel('bar');
expectInactiveTabAndPanel('foo');
});
});
});
......@@ -7,17 +7,13 @@ RSpec.describe TabHelper do
describe 'gl_tabs_nav' do
it 'creates a tabs navigation' do
expect(helper.gl_tabs_nav).to match(%r{<ul class=".*" role="tablist"><\/ul>})
expect(helper.gl_tabs_nav).to match(%r{<ul class="nav gl-tabs-nav"><\/ul>})
end
it 'captures block output' do
expect(helper.gl_tabs_nav { "block content" }).to match(/block content/)
end
it 'adds styles classes' do
expect(helper.gl_tabs_nav).to match(/class="nav gl-tabs-nav"/)
end
it 'adds custom class' do
expect(helper.gl_tabs_nav(class: 'my-class' )).to match(/class=".*my-class.*"/)
end
......@@ -29,7 +25,7 @@ RSpec.describe TabHelper do
end
it 'creates a tab' do
expect(helper.gl_tab_link_to('Link', '/url')).to eq('<li class="nav-item" role="presentation"><a class="nav-link gl-tab-nav-item" href="/url">Link</a></li>')
expect(helper.gl_tab_link_to('Link', '/url')).to eq('<li class="nav-item"><a class="nav-link gl-tab-nav-item" href="/url">Link</a></li>')
end
it 'creates a tab with block output' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment