From d2e7305df54b5fda1656a9f510759f2be960290d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20M=C3=BCller?= Date: Thu, 28 Jan 2021 15:38:35 +0100 Subject: [PATCH] feat: add `headerTag` and `footerTag` props to all componets with header and footer --- src/components/calendar/calendar.js | 7 +-- src/components/calendar/calendar.spec.js | 20 ++++++++ src/components/calendar/package.json | 5 ++ src/components/modal/modal.js | 6 ++- src/components/modal/modal.spec.js | 42 ++++++++++++++++ src/components/modal/package.json | 10 ++++ src/components/sidebar/package.json | 10 ++++ src/components/sidebar/sidebar.js | 6 ++- src/components/sidebar/sidebar.spec.js | 15 ++++-- src/components/time/package.json | 10 ++++ src/components/time/time.js | 64 ++++++++++++++---------- src/components/time/time.spec.js | 41 +++++++++++++++ src/components/toast/package.json | 5 ++ src/components/toast/toast.js | 8 ++- src/components/toast/toast.spec.js | 28 +++++++++++ 15 files changed, 237 insertions(+), 40 deletions(-) diff --git a/src/components/calendar/calendar.js b/src/components/calendar/calendar.js index 07005f1ff9c..b68013a1910 100644 --- a/src/components/calendar/calendar.js +++ b/src/components/calendar/calendar.js @@ -114,6 +114,7 @@ export const props = makePropsConfigurable( // 'ltr', 'rtl', or `null` (for auto detect) direction: makeProp(PROP_TYPE_STRING), disabled: makeProp(PROP_TYPE_BOOLEAN, false), + headerTag: makeProp(PROP_TYPE_STRING, 'header'), // When `true`, renders a comment node, but keeps the component instance active // Mainly for , so that we can get the component's value and locale // But we might just use separate date formatters, using the resolved locale @@ -806,7 +807,7 @@ export const BCalendar = Vue.extend({ : this.labelNoDateSelected || '\u00a0' // ' ' ) $header = h( - 'header', + this.headerTag, { staticClass: 'b-calendar-header', class: { 'sr-only': this.hideHeader }, @@ -936,7 +937,7 @@ export const BCalendar = Vue.extend({ // Caption for calendar grid const $gridCaption = h( - 'header', + 'div', { staticClass: 'b-calendar-grid-caption text-center font-weight-bold', class: { 'text-muted': disabled }, @@ -1065,7 +1066,7 @@ export const BCalendar = Vue.extend({ ) const $gridHelp = h( - 'footer', + 'div', { staticClass: 'b-calendar-grid-help border-top small text-muted text-center bg-light', attrs: { diff --git a/src/components/calendar/calendar.spec.js b/src/components/calendar/calendar.spec.js index 1628d9ce456..2878cf2dbc4 100644 --- a/src/components/calendar/calendar.spec.js +++ b/src/components/calendar/calendar.spec.js @@ -244,6 +244,26 @@ describe('calendar', () => { wrapper.destroy() }) + it('has correct header tag when "header-tag" prop is set', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + value: '2020-02-15', // Leap year, + headerTag: 'div' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.b-calendar-header') + expect($header.exists()).toBe(true) + expect($header.element.tagName).toBe('DIV') + + wrapper.destroy() + }) + it('keyboard navigation works', async () => { const wrapper = mount(BCalendar, { attachTo: createContainer(), diff --git a/src/components/calendar/package.json b/src/components/calendar/package.json index fcfbabdda05..60e7f43da44 100644 --- a/src/components/calendar/package.json +++ b/src/components/calendar/package.json @@ -36,6 +36,11 @@ "prop": "disabled", "description": "Places the calendar in a non-interactive disabled state" }, + { + "prop": "headerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, { "prop": "hidden", "description": "When `true`, renders a comment node instead of the calendar widget while keeping the Vue instance active. Mainly used when implementing a custom date picker" diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index fa8a860ac19..353b10e4b00 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -128,6 +128,7 @@ export const props = makePropsConfigurable( footerBgVariant: makeProp(PROP_TYPE_STRING), footerBorderVariant: makeProp(PROP_TYPE_STRING), footerClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), + footerTag: makeProp(PROP_TYPE_STRING, 'footer'), footerTextVariant: makeProp(PROP_TYPE_STRING), headerBgVariant: makeProp(PROP_TYPE_STRING), headerBorderVariant: makeProp(PROP_TYPE_STRING), @@ -135,6 +136,7 @@ export const props = makePropsConfigurable( headerCloseContent: makeProp(PROP_TYPE_STRING, '×'), headerCloseLabel: makeProp(PROP_TYPE_STRING, 'Close'), headerCloseVariant: makeProp(PROP_TYPE_STRING), + headerTag: makeProp(PROP_TYPE_STRING, 'header'), headerTextVariant: makeProp(PROP_TYPE_STRING), // TODO: Rename to `noBackdrop` and deprecate `hideBackdrop` hideBackdrop: makeProp(PROP_TYPE_BOOLEAN, false), @@ -813,7 +815,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ } $header = h( - 'header', + this.headerTag, { staticClass: 'modal-header', class: this.headerClasses, @@ -887,7 +889,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ } $footer = h( - 'footer', + this.footerTag, { staticClass: 'modal-footer', class: this.footerClasses, diff --git a/src/components/modal/modal.spec.js b/src/components/modal/modal.spec.js index 2eba663aa3f..67207053a81 100644 --- a/src/components/modal/modal.spec.js +++ b/src/components/modal/modal.spec.js @@ -262,6 +262,48 @@ describe('modal', () => { wrapper.destroy() }) + + it('has correct header tag when "header-tag" prop is set', async () => { + const wrapper = mount(BModal, { + attachTo: createContainer(), + propsData: { + static: true, + id: 'test', + headerTag: 'div' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.modal-header') + expect($header.exists()).toBe(true) + expect($header.element.tagName).toBe('DIV') + + wrapper.destroy() + }) + + it('has correct footer tag when "footer-tag" prop is set', async () => { + const wrapper = mount(BModal, { + attachTo: createContainer(), + propsData: { + static: true, + id: 'test', + footerTag: 'div' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $footer = wrapper.find('.modal-footer') + expect($footer.exists()).toBe(true) + expect($footer.element.tagName).toBe('DIV') + + wrapper.destroy() + }) }) describe('default button content, classes and attributes', () => { diff --git a/src/components/modal/package.json b/src/components/modal/package.json index 0a7345c2fe5..28c2c692dc2 100644 --- a/src/components/modal/package.json +++ b/src/components/modal/package.json @@ -104,6 +104,11 @@ "prop": "footerTextVariant", "description": "Applies one of the Bootstrap theme color variants to the footer text" }, + { + "prop": "footerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, { "prop": "headerBgVariant", "description": "Applies one of the Bootstrap theme color variants to the header background" @@ -133,6 +138,11 @@ "prop": "headerTextVariant", "description": "Applies one of the Bootstrap theme color variants to the header text" }, + { + "prop": "headerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, { "prop": "hideBackdrop", "description": "Disables rendering of the modal backdrop" diff --git a/src/components/sidebar/package.json b/src/components/sidebar/package.json index f82709ab5f9..f083bd74f45 100644 --- a/src/components/sidebar/package.json +++ b/src/components/sidebar/package.json @@ -39,10 +39,20 @@ "prop": "footerClass", "description": "Class, or classes, to apply to the optional `footer` slot" }, + { + "prop": "footerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, { "prop": "headerClass", "description": "Class, or classes, to apply to the built in header. Has no effect if prop `no-header` is set" }, + { + "prop": "headerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, { "prop": "lazy", "description": "When set to `true`, the content of the sidebar will only be rendered while the sidebar is open" diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js index 8afb7c0b20a..5d676ebc22f 100644 --- a/src/components/sidebar/sidebar.js +++ b/src/components/sidebar/sidebar.js @@ -67,7 +67,9 @@ export const props = makePropsConfigurable( // `aria-label` for close button closeLabel: makeProp(PROP_TYPE_STRING), footerClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), + footerTag: makeProp(PROP_TYPE_STRING, 'footer'), headerClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), + headerTag: makeProp(PROP_TYPE_STRING, 'header'), lazy: makeProp(PROP_TYPE_BOOLEAN, false), noCloseOnBackdrop: makeProp(PROP_TYPE_BOOLEAN, false), noCloseOnEsc: makeProp(PROP_TYPE_BOOLEAN, false), @@ -131,7 +133,7 @@ const renderHeader = (h, ctx) => { } return h( - 'header', + ctx.headerTag, { staticClass: `${CLASS_NAME}-header`, class: ctx.headerClass, @@ -160,7 +162,7 @@ const renderFooter = (h, ctx) => { } return h( - 'footer', + ctx.footerTag, { staticClass: `${CLASS_NAME}-footer`, class: ctx.footerClass, diff --git a/src/components/sidebar/sidebar.spec.js b/src/components/sidebar/sidebar.spec.js index 015c2eb53ce..2b13b70f054 100644 --- a/src/components/sidebar/sidebar.spec.js +++ b/src/components/sidebar/sidebar.spec.js @@ -331,7 +331,8 @@ describe('sidebar', () => { propsData: { id: 'sidebar-header-slot', visible: true, - title: 'TITLE' + title: 'TITLE', + headerTag: 'div' }, slots: { header: 'Custom header' @@ -343,6 +344,7 @@ describe('sidebar', () => { const $header = wrapper.find('.b-sidebar-header') expect($header.exists()).toBe(true) + expect($header.element.tagName).toBe('DIV') expect($header.find('strong').exists()).toBe(false) expect($header.find('button').exists()).toBe(false) expect($header.text()).toContain('Custom header') @@ -358,7 +360,8 @@ describe('sidebar', () => { attachTo: createContainer(), propsData: { id: 'test-5', - visible: true + visible: true, + footerTag: 'div' }, slots: { footer: 'FOOTER' @@ -367,10 +370,14 @@ describe('sidebar', () => { expect(wrapper.vm).toBeDefined() expect(wrapper.element.tagName).toBe('DIV') + expect(wrapper.find('.b-sidebar-header').exists()).toBe(true) expect(wrapper.find('.b-sidebar-body').exists()).toBe(true) - expect(wrapper.find('.b-sidebar-footer').exists()).toBe(true) - expect(wrapper.find('.b-sidebar-footer').text()).toEqual('FOOTER') + + const $footer = wrapper.find('.b-sidebar-footer') + expect($footer.exists()).toBe(true) + expect($footer.element.tagName).toBe('DIV') + expect($footer.text()).toEqual('FOOTER') wrapper.destroy() }) diff --git a/src/components/time/package.json b/src/components/time/package.json index 803d7bb6509..2b8521f7373 100644 --- a/src/components/time/package.json +++ b/src/components/time/package.json @@ -10,6 +10,16 @@ "component": "BTime", "version": "2.6.0", "props": [ + { + "prop": "footerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, + { + "prop": "headerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, { "prop": "hideHeader", "description": "When set, visually hides the selected time header" diff --git a/src/components/time/time.js b/src/components/time/time.js index 8be9cba0753..e94665ec06d 100644 --- a/src/components/time/time.js +++ b/src/components/time/time.js @@ -78,6 +78,8 @@ export const props = makePropsConfigurable( // ID of label element ariaLabelledby: makeProp(PROP_TYPE_STRING), disabled: makeProp(PROP_TYPE_BOOLEAN, false), + footerTag: makeProp(PROP_TYPE_STRING, 'footer'), + headerTag: makeProp(PROP_TYPE_STRING, 'header'), hidden: makeProp(PROP_TYPE_BOOLEAN, false), hideHeader: makeProp(PROP_TYPE_BOOLEAN, false), // Explicitly force 12 or 24 hour time @@ -389,14 +391,22 @@ export const BTime = /*#__PURE__*/ Vue.extend({ } }, render(h) { + // If hidden, we just render a placeholder comment /* istanbul ignore if */ if (this.hidden) { - // If hidden, we just render a placeholder comment return h() } - const valueId = this.valueId - const computedAriaLabelledby = this.computedAriaLabelledby + const { + disabled, + readonly, + computedLocale: locale, + computedAriaLabelledby: ariaLabelledby, + labelIncrement, + labelDecrement, + valueId, + focus: focusHandler + } = this const spinIds = [] // Helper method to render a spinbutton @@ -411,11 +421,11 @@ export const BTime = /*#__PURE__*/ Vue.extend({ placeholder: '--', vertical: true, required: true, - disabled: this.disabled, - readonly: this.readonly, - locale: this.computedLocale, - labelIncrement: this.labelIncrement, - labelDecrement: this.labelDecrement, + disabled, + readonly, + locale, + labelIncrement, + labelDecrement, wrap: true, ariaControls: valueId, min: 0, @@ -441,9 +451,7 @@ export const BTime = /*#__PURE__*/ Vue.extend({ 'div', { staticClass: 'd-flex flex-column', - class: { - 'text-muted': this.disabled || this.readonly - }, + class: { 'text-muted': disabled || readonly }, attrs: { 'aria-hidden': 'true' } }, [ @@ -520,14 +528,14 @@ export const BTime = /*#__PURE__*/ Vue.extend({ staticClass: 'd-flex align-items-center justify-content-center mx-auto', attrs: { role: 'group', - tabindex: this.disabled || this.readonly ? null : '-1', - 'aria-labelledby': computedAriaLabelledby + tabindex: disabled || readonly ? null : '-1', + 'aria-labelledby': ariaLabelledby }, on: { keydown: this.onSpinLeftRight, click: /* istanbul ignore next */ event => { if (event.target === event.currentTarget) { - this.focus() + focusHandler() } } } @@ -541,20 +549,20 @@ export const BTime = /*#__PURE__*/ Vue.extend({ { staticClass: 'form-control form-control-sm text-center', class: { - disabled: this.disabled || this.readonly + disabled: disabled || readonly }, attrs: { id: valueId, role: 'status', for: spinIds.filter(identity).join(' ') || null, - tabindex: this.disabled ? null : '-1', + tabindex: disabled ? null : '-1', 'aria-live': this.isLive ? 'polite' : 'off', 'aria-atomic': 'true' }, on: { // Transfer focus/click to focus hours spinner - click: this.focus, - focus: this.focus + click: focusHandler, + focus: focusHandler } }, [ @@ -563,14 +571,16 @@ export const BTime = /*#__PURE__*/ Vue.extend({ ] ) const $header = h( - 'header', - { staticClass: 'b-time-header', class: { 'sr-only': this.hideHeader } }, + this.headerTag, + { + staticClass: 'b-time-header', + class: { 'sr-only': this.hideHeader } + }, [$value] ) - // Optional bottom slot - let $slot = this.normalizeSlot() - $slot = $slot ? h('footer', { staticClass: 'b-time-footer' }, $slot) : h() + const $content = this.normalizeSlot() + const $footer = $content ? h(this.footerTag, { staticClass: 'b-time-footer' }, $content) : h() return h( 'div', @@ -579,12 +589,12 @@ export const BTime = /*#__PURE__*/ Vue.extend({ attrs: { role: 'group', lang: this.computedLang || null, - 'aria-labelledby': computedAriaLabelledby || null, - 'aria-disabled': this.disabled ? 'true' : null, - 'aria-readonly': this.readonly && !this.disabled ? 'true' : null + 'aria-labelledby': ariaLabelledby || null, + 'aria-disabled': disabled ? 'true' : null, + 'aria-readonly': readonly && !disabled ? 'true' : null } }, - [$header, $spinners, $slot] + [$header, $spinners, $footer] ) } }) diff --git a/src/components/time/time.spec.js b/src/components/time/time.spec.js index d1299877ad6..7033796b696 100644 --- a/src/components/time/time.spec.js +++ b/src/components/time/time.spec.js @@ -145,6 +145,47 @@ describe('time', () => { wrapper.destroy() }) + it('has correct header tag when "header-tag" prop is set', async () => { + const wrapper = mount(BTime, { + attachTo: createContainer(), + propsData: { + headerTag: 'div' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.b-time-header') + expect($header.exists()).toBe(true) + expect($header.element.tagName).toBe('DIV') + + wrapper.destroy() + }) + + it('has correct footer tag when "footer-tag" prop is set', async () => { + const wrapper = mount(BTime, { + attachTo: createContainer(), + propsData: { + footerTag: 'div' + }, + slots: { + default: 'text' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $footer = wrapper.find('.b-time-footer') + expect($footer.exists()).toBe(true) + expect($footer.element.tagName).toBe('DIV') + + wrapper.destroy() + }) + it('spin buttons work', async () => { const wrapper = mount(BTime, { propsData: { diff --git a/src/components/toast/package.json b/src/components/toast/package.json index ddce5c18290..fe943ffefac 100644 --- a/src/components/toast/package.json +++ b/src/components/toast/package.json @@ -28,6 +28,11 @@ "prop": "headerClass", "description": "CSS class (or classes) to add to the toast header element" }, + { + "prop": "headerTag", + "version": "2.22.0", + "description": "Specify the HTML tag to render instead of the default tag for the footer" + }, { "prop": "isStatus", "description": "When set to 'true', makes the toast have attributes aria-live=polite and role=status. When 'false' aria-live will be 'assertive' and role will be 'alert'" diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index 542a23530e2..42294e29542 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -64,6 +64,7 @@ export const props = makePropsConfigurable( autoHideDelay: makeProp(PROP_TYPE_NUMBER_STRING, 5000), bodyClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), headerClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), + headerTag: makeProp(PROP_TYPE_STRING, 'header'), // Switches role to 'status' and aria-live to 'polite' isStatus: makeProp(PROP_TYPE_BOOLEAN, false), noAutoHide: makeProp(PROP_TYPE_BOOLEAN, false), @@ -366,8 +367,11 @@ export const BToast = /*#__PURE__*/ Vue.extend({ let $header = h() if ($headerContent.length > 0) { $header = h( - 'header', - { staticClass: 'toast-header', class: this.headerClass }, + this.headerTag, + { + staticClass: 'toast-header', + class: this.headerClass + }, $headerContent ) } diff --git a/src/components/toast/toast.spec.js b/src/components/toast/toast.spec.js index 63fcaf746dc..ba1a5a646e3 100644 --- a/src/components/toast/toast.spec.js +++ b/src/components/toast/toast.spec.js @@ -67,6 +67,34 @@ describe('b-toast', () => { wrapper.destroy() }) + it('has correct header tag when "header-tag" prop is set', async () => { + const wrapper = mount(BToast, { + attachTo: createContainer(), + propsData: { + static: true, + noAutoHide: true, + visible: true, + title: 'title', + headerTag: 'div' + }, + slots: { + default: 'content' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.toast-header') + expect($header.exists()).toBe(true) + expect($header.element.tagName).toBe('DIV') + + wrapper.destroy() + }) + it('visible prop works', async () => { const wrapper = mount(BToast, { attachTo: createContainer(),