diff --git a/src/components/form-datepicker/README.md b/src/components/form-datepicker/README.md index 6c25c05716d..ce8ddf5c01e 100644 --- a/src/components/form-datepicker/README.md +++ b/src/components/form-datepicker/README.md @@ -313,6 +313,58 @@ component's value. Want a fancy popup with a dark background instead of a light background? Set the `dark` prop to `true` to enable the dark background. +### Button only mode + +v2.7.0+ + +Fancy just a button that launches the date picker dialog, or want to provide your own optional text +input field? Use the `button-only` prop to render the datepicker as a dropdown button. The formatted +date label will be rendered with the class `sr-only` (available only to screen readers). + +In the following simple example, we are placing the datepicker (button only mode) as an append to a +``: + +```html + + + + + +``` + +Control the size of the button via the `size` prop, and the button variant via the `button-variant` +prop. + ### Date string format v2.6.0+ @@ -331,7 +383,8 @@ properties for the `Intl.DateTimeFormat` object (see also :date-format-options="{ year: 'numeric', month: 'short', day: '2-digit', weekday: 'short' }" locale="en" > - + + { await waitRAF() expect(wrapper.classes()).toContain('b-form-datepicker') + expect(wrapper.classes()).toContain('b-form-btn-label-control') expect(wrapper.classes()).toContain('form-control') expect(wrapper.classes()).toContain('dropdown') expect(wrapper.classes()).not.toContain('show') + expect(wrapper.classes()).not.toContain('btn-group') expect(wrapper.attributes('role')).toEqual('group') expect(wrapper.find('.dropdown-menu').exists()).toBe(true) @@ -54,6 +56,7 @@ describe('form-date', () => { expect(wrapper.find('label.form-control').exists()).toBe(true) expect(wrapper.find('label.form-control').attributes('for')).toEqual('test-base') + expect(wrapper.find('label.form-control').classes()).not.toContain('sr-only') expect(wrapper.find('input[type="hidden"]').exists()).toBe(false) @@ -66,6 +69,48 @@ describe('form-date', () => { wrapper.destroy() }) + it('has expected base structure in button-only mode', async () => { + const wrapper = mount(BFormDatepicker, { + attachToDocument: true, + propsData: { + id: 'test-button-only', + buttonOnly: true + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('div')).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.classes()).toContain('b-form-datepicker') + expect(wrapper.classes()).not.toContain('b-form-btn-label-control') + expect(wrapper.classes()).not.toContain('form-control') + expect(wrapper.classes()).toContain('dropdown') + expect(wrapper.classes()).not.toContain('show') + expect(wrapper.classes()).toContain('btn-group') + expect(wrapper.attributes('role')).not.toEqual('group') + + expect(wrapper.find('.dropdown-menu').exists()).toBe(true) + expect(wrapper.find('.dropdown-menu').classes()).not.toContain('show') + expect(wrapper.find('.dropdown-menu').attributes('role')).toEqual('dialog') + expect(wrapper.find('.dropdown-menu').attributes('aria-modal')).toEqual('false') + + expect(wrapper.find('label.form-control').exists()).toBe(true) + expect(wrapper.find('label.form-control').attributes('for')).toEqual('test-button-only') + expect(wrapper.find('label.form-control').classes()).toContain('sr-only') + + expect(wrapper.find('input[type="hidden"]').exists()).toBe(false) + + const $btn = wrapper.find('button#test-button-only') + expect($btn.exists()).toBe(true) + expect($btn.attributes('aria-haspopup')).toEqual('dialog') + expect($btn.attributes('aria-expanded')).toEqual('false') + expect($btn.find('svg.bi-calendar').exists()).toBe(true) + + wrapper.destroy() + }) + it('renders hidden input when name prop is set', async () => { const wrapper = mount(BFormDatepicker, { attachToDocument: true, diff --git a/src/components/form-datepicker/package.json b/src/components/form-datepicker/package.json index 073a2515dbb..b0f3f7fffab 100644 --- a/src/components/form-datepicker/package.json +++ b/src/components/form-datepicker/package.json @@ -69,6 +69,16 @@ "prop": "direction", "description": "Set to the string 'rtl' or 'ltr' to explicitly force the calendar to render in right-to-left or left-ro-right (respectively) mode. Defaults to the resolved locale's directionality" }, + { + "prop": "buttonOnly", + "version": "2.7.0", + "description": "Renders the datepicker as a dropdown button instead of a form-control" + }, + { + "prop": "buttonVariant", + "version": "2.7.0", + "description": "The button variant to use when in `button-only` mode. Has no effect if prop `button-only` is not set" + }, { "prop": "calendarWidth", "version": "2.6.0", diff --git a/src/components/form-timepicker/README.md b/src/components/form-timepicker/README.md index 300eda4d93e..a389323f862 100644 --- a/src/components/form-timepicker/README.md +++ b/src/components/form-timepicker/README.md @@ -208,6 +208,60 @@ control the positioning of the popup calendar. Refer to the [`` documentation](/docs/components/dropdown) for details on the effects and usage of these props. +### Button only mode + +v2.7.0+ + +Fancy just a button that launches the timepicker dialog, or want to provide your own optional text +input field? Use the `button-only` prop to render the timepicker as a dropdown button. The formatted +time label will be rendered with the class `sr-only` (available only to screen readers). + +In the following simple example, we are placing the timepicker (button only mode) as an append to a +``: + +```html + + + + + +``` + +Control the size of the button via the `size` prop, and the button variant via the `button-variant` +prop. + ## Internationalization Internationalization of the time interface is provided via diff --git a/src/components/form-timepicker/form-timepicker.js b/src/components/form-timepicker/form-timepicker.js index b15f8a12032..d36fec0e76f 100644 --- a/src/components/form-timepicker/form-timepicker.js +++ b/src/components/form-timepicker/form-timepicker.js @@ -90,6 +90,15 @@ const propsMixin = { type: [Number, String], default: 1 }, + buttonOnly: { + type: Boolean, + default: false + }, + buttonVariant: { + // Applicable in button only mode + type: String, + default: 'secondary' + }, nowButton: { type: Boolean, default: false @@ -240,7 +249,12 @@ export const BFormTimepicker = /*#__PURE__*/ Vue.extend({ this.localHMS = newVal || '' }, localHMS(newVal) { - this.$emit('input', newVal || '') + // We only update hte v-model value when the timepicker + // is open, to prevent cursor jumps when bound to a + // text input in button only mode + if (this.isVisible) { + this.$emit('input', newVal || '') + } } }, methods: { diff --git a/src/components/form-timepicker/form-timepicker.spec.js b/src/components/form-timepicker/form-timepicker.spec.js index fb27cd9c75d..b6fcd475eb7 100644 --- a/src/components/form-timepicker/form-timepicker.spec.js +++ b/src/components/form-timepicker/form-timepicker.spec.js @@ -41,9 +41,11 @@ describe('form-timepicker', () => { await waitRAF() expect(wrapper.classes()).toContain('b-form-timepicker') + expect(wrapper.classes()).toContain('b-form-btn-label-control') expect(wrapper.classes()).toContain('form-control') expect(wrapper.classes()).toContain('dropdown') expect(wrapper.classes()).not.toContain('show') + expect(wrapper.classes()).not.toContain('btn-group') expect(wrapper.attributes('role')).toEqual('group') expect(wrapper.find('.dropdown-menu').exists()).toBe(true) @@ -66,6 +68,49 @@ describe('form-timepicker', () => { wrapper.destroy() }) + it('has expected default structure when button-only is true', async () => { + const wrapper = mount(BFormTimepicker, { + attachToDocument: true, + propsData: { + id: 'test-button-only', + buttonOnly: true + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('div')).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.classes()).toContain('b-form-timepicker') + expect(wrapper.classes()).not.toContain('b-form-btn-label-control') + expect(wrapper.classes()).not.toContain('form-control') + expect(wrapper.classes()).toContain('dropdown') + expect(wrapper.classes()).not.toContain('show') + expect(wrapper.classes()).toContain('btn-group') + expect(wrapper.attributes('role')).not.toEqual('group') + + expect(wrapper.find('.dropdown-menu').exists()).toBe(true) + expect(wrapper.find('.dropdown-menu').classes()).not.toContain('show') + expect(wrapper.find('.dropdown-menu').attributes('role')).toEqual('dialog') + expect(wrapper.find('.dropdown-menu').attributes('aria-modal')).toEqual('false') + + expect(wrapper.find('label.form-control').exists()).toBe(true) + expect(wrapper.find('label.form-control').attributes('for')).toEqual('test-button-only') + expect(wrapper.find('label.form-control').text()).toContain('No time selected') + expect(wrapper.find('label.form-control').classes()).toContain('sr-only') + + expect(wrapper.find('input[type="hidden"]').exists()).toBe(false) + + const $btn = wrapper.find('button#test-button-only') + expect($btn.exists()).toBe(true) + expect($btn.attributes('aria-haspopup')).toEqual('dialog') + expect($btn.attributes('aria-expanded')).toEqual('false') + expect($btn.find('svg.bi-clock').exists()).toBe(true) + + wrapper.destroy() + }) + it('renders hidden input when name prop is set', async () => { const wrapper = mount(BFormTimepicker, { attachToDocument: true, diff --git a/src/components/form-timepicker/package.json b/src/components/form-timepicker/package.json index 92342824416..4e006db1f10 100644 --- a/src/components/form-timepicker/package.json +++ b/src/components/form-timepicker/package.json @@ -62,6 +62,16 @@ "prop": "hideHeader", "description": "When set, visually hides the selected date header" }, + { + "prop": "buttonOnly", + "version": "2.7.0", + "description": "Renders the datepicker as a dropdown button instead of a form-control" + }, + { + "prop": "buttonVariant", + "version": "2.7.0", + "description": "The button variant to use when in `button-only` mode. Has no effect if prop `button-only` is not set" + }, { "prop": "menuClass", "description": "Class (or classes) to apply to to popup menu wrapper" diff --git a/src/utils/bv-form-btn-label-control.js b/src/utils/bv-form-btn-label-control.js index a4da96620b8..e44fe4cffca 100644 --- a/src/utils/bv-form-btn-label-control.js +++ b/src/utils/bv-form-btn-label-control.js @@ -83,6 +83,16 @@ export const BVFormBtnLabelControl = /*#__PURE__*/ Vue.extend({ // Vue coerces `undefined` into Boolean `false` default: null }, + buttonOnly: { + // When true, renders a btn-group wrapper and visually hides the label + type: Boolean, + default: false + }, + buttonVariant: { + // Applicable in button mode only + type: String, + default: 'secondary' + }, menuClass: { // Extra classes to apply to the `dropdown-menu` div type: [String, Array, Object] @@ -152,14 +162,26 @@ export const BVFormBtnLabelControl = /*#__PURE__*/ Vue.extend({ const size = this.size const value = toString(this.value) || '' const labelSelected = this.labelSelected + const buttonOnly = !!this.buttonOnly + const buttonVariant = this.buttonVariant const btnScope = { isHovered, hasFocus, state, opened: visible } const $button = h( 'button', { ref: 'toggle', - staticClass: 'btn border-0 h-auto py-0', - class: { [`btn-${size}`]: !!size }, + staticClass: 'btn', + class: { + [`btn-${buttonVariant}`]: buttonOnly, + [`btn-${size}`]: !!size, + 'border-0': !buttonOnly, + 'h-auto': !buttonOnly, + 'py-0': !buttonOnly, + // `dropdown-toggle` is needed for proper + // corner rounding in button only mode + 'dropdown-toggle': buttonOnly, + 'dropdown-toggle-no-caret': buttonOnly + }, attrs: { id: idButton, type: 'button', @@ -231,6 +253,8 @@ export const BVFormBtnLabelControl = /*#__PURE__*/ Vue.extend({ { staticClass: 'form-control text-break text-wrap border-0 bg-transparent h-auto pl-1 m-0', class: { + // Hidden in button only mode + 'sr-only': buttonOnly, // Mute the text if showing the placeholder 'text-muted': !value, [`form-control-${size}`]: !!size, @@ -261,21 +285,27 @@ export const BVFormBtnLabelControl = /*#__PURE__*/ Vue.extend({ return h( 'div', { - staticClass: - 'b-form-btn-label-control form-control dropdown d-flex p-0 h-auto align-items-stretch', + staticClass: 'dropdown', class: [ this.directionClass, { + 'btn-group': buttonOnly, + 'b-form-btn-label-control': !buttonOnly, + 'form-control': !buttonOnly, + [`form-control-${size}`]: !!size && !buttonOnly, + 'd-flex': !buttonOnly, + 'p-0': !buttonOnly, + 'h-auto': !buttonOnly, + 'align-items-stretch': !buttonOnly, + focus: hasFocus && !buttonOnly, show: visible, - focus: hasFocus, - [`form-control-${size}`]: !!size, 'is-valid': state === true, 'is-invalid': state === false } ], attrs: { id: idWrapper, - role: 'group', + role: buttonOnly ? null : 'group', lang: this.lang || null, dir: this.computedDir, 'aria-disabled': disabled,