Skip to content

Commit 13660c3

Browse files
authored
feat(b-form-datepicker, b-form-timepicker): add support for icon button only mode (closes #4888) (#4915)
Co-authored-by: Jacob Müller
1 parent 1d957eb commit 13660c3

File tree

9 files changed

+283
-10
lines changed

9 files changed

+283
-10
lines changed

src/components/form-datepicker/README.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,58 @@ component's value.
313313
Want a fancy popup with a dark background instead of a light background? Set the `dark` prop to
314314
`true` to enable the dark background.
315315

316+
### Button only mode
317+
318+
<span class="badge badge-info small">v2.7.0+</span>
319+
320+
Fancy just a button that launches the date picker dialog, or want to provide your own optional text
321+
input field? Use the `button-only` prop to render the datepicker as a dropdown button. The formatted
322+
date label will be rendered with the class `sr-only` (available only to screen readers).
323+
324+
In the following simple example, we are placing the datepicker (button only mode) as an append to a
325+
`<b-input-group>`:
326+
327+
```html
328+
<template>
329+
<div>
330+
<label for="example-input">Choose a date</label>
331+
<b-input-group class="mb-3">
332+
<b-form-input
333+
id="example-input"
334+
v-model="value"
335+
type="text"
336+
placeholder="YYYY-MM-DD"
337+
></b-form-input>
338+
<b-input-group-append>
339+
<b-form-datepicker
340+
v-model="value"
341+
button-only
342+
right
343+
locale="en-US"
344+
aria-controls="example-input"
345+
></b-form-datepicker>
346+
</b-input-group-append">
347+
</b-input-group>
348+
<p>Value: '{{ value }}'</p>
349+
</div>
350+
</template>
351+
352+
<script>
353+
export default {
354+
data() {
355+
return {
356+
value: ''
357+
}
358+
}
359+
}
360+
</script>
361+
362+
<!-- b-form-datepicker-button-only.vue -->
363+
```
364+
365+
Control the size of the button via the `size` prop, and the button variant via the `button-variant`
366+
prop.
367+
316368
### Date string format
317369
318370
<span class="badge badge-info small">v2.6.0+</span>
@@ -331,7 +383,8 @@ properties for the `Intl.DateTimeFormat` object (see also
331383
:date-format-options="{ year: 'numeric', month: 'short', day: '2-digit', weekday: 'short' }"
332384
locale="en"
333385
></b-form-datepicker>
334-
<label for="datepicker-dateformat2">Short date format</label>
386+
387+
<label class="mt-3" for="datepicker-dateformat2">Short date format</label>
335388
<b-form-datepicker
336389
id="datepicker-dateformat2"
337390
:date-format-options="{ year: 'numeric', month: 'numeric', day: 'numeric' }"

src/components/form-datepicker/form-datepicker.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ const propsMixin = {
108108
type: String,
109109
default: null
110110
},
111+
buttonOnly: {
112+
type: Boolean,
113+
default: false
114+
},
115+
buttonVariant: {
116+
// Applicable in button only mode
117+
type: String,
118+
default: 'secondary'
119+
},
111120
calendarWidth: {
112121
// Width of the calendar dropdown
113122
type: String,
@@ -310,7 +319,10 @@ export const BFormDatepicker = /*#__PURE__*/ Vue.extend({
310319
this.localYMD = formatYMD(newVal) || ''
311320
},
312321
localYMD(newVal) {
313-
this.$emit('input', this.valueAsDate ? parseYMD(newVal) || null : newVal || '')
322+
// We only update the v-model when the datepicker is open
323+
if (this.isVisible) {
324+
this.$emit('input', this.valueAsDate ? parseYMD(newVal) || null : newVal || '')
325+
}
314326
},
315327
calendarYM(newVal, oldVal) /* istanbul ignore next */ {
316328
// Displayed calendar month has changed

src/components/form-datepicker/form-datepicker.spec.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ describe('form-date', () => {
4242
await waitRAF()
4343

4444
expect(wrapper.classes()).toContain('b-form-datepicker')
45+
expect(wrapper.classes()).toContain('b-form-btn-label-control')
4546
expect(wrapper.classes()).toContain('form-control')
4647
expect(wrapper.classes()).toContain('dropdown')
4748
expect(wrapper.classes()).not.toContain('show')
49+
expect(wrapper.classes()).not.toContain('btn-group')
4850
expect(wrapper.attributes('role')).toEqual('group')
4951

5052
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
@@ -54,6 +56,7 @@ describe('form-date', () => {
5456

5557
expect(wrapper.find('label.form-control').exists()).toBe(true)
5658
expect(wrapper.find('label.form-control').attributes('for')).toEqual('test-base')
59+
expect(wrapper.find('label.form-control').classes()).not.toContain('sr-only')
5760

5861
expect(wrapper.find('input[type="hidden"]').exists()).toBe(false)
5962

@@ -66,6 +69,48 @@ describe('form-date', () => {
6669
wrapper.destroy()
6770
})
6871

72+
it('has expected base structure in button-only mode', async () => {
73+
const wrapper = mount(BFormDatepicker, {
74+
attachToDocument: true,
75+
propsData: {
76+
id: 'test-button-only',
77+
buttonOnly: true
78+
}
79+
})
80+
81+
expect(wrapper.isVueInstance()).toBe(true)
82+
expect(wrapper.is('div')).toBe(true)
83+
await waitNT(wrapper.vm)
84+
await waitRAF()
85+
86+
expect(wrapper.classes()).toContain('b-form-datepicker')
87+
expect(wrapper.classes()).not.toContain('b-form-btn-label-control')
88+
expect(wrapper.classes()).not.toContain('form-control')
89+
expect(wrapper.classes()).toContain('dropdown')
90+
expect(wrapper.classes()).not.toContain('show')
91+
expect(wrapper.classes()).toContain('btn-group')
92+
expect(wrapper.attributes('role')).not.toEqual('group')
93+
94+
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
95+
expect(wrapper.find('.dropdown-menu').classes()).not.toContain('show')
96+
expect(wrapper.find('.dropdown-menu').attributes('role')).toEqual('dialog')
97+
expect(wrapper.find('.dropdown-menu').attributes('aria-modal')).toEqual('false')
98+
99+
expect(wrapper.find('label.form-control').exists()).toBe(true)
100+
expect(wrapper.find('label.form-control').attributes('for')).toEqual('test-button-only')
101+
expect(wrapper.find('label.form-control').classes()).toContain('sr-only')
102+
103+
expect(wrapper.find('input[type="hidden"]').exists()).toBe(false)
104+
105+
const $btn = wrapper.find('button#test-button-only')
106+
expect($btn.exists()).toBe(true)
107+
expect($btn.attributes('aria-haspopup')).toEqual('dialog')
108+
expect($btn.attributes('aria-expanded')).toEqual('false')
109+
expect($btn.find('svg.bi-calendar').exists()).toBe(true)
110+
111+
wrapper.destroy()
112+
})
113+
69114
it('renders hidden input when name prop is set', async () => {
70115
const wrapper = mount(BFormDatepicker, {
71116
attachToDocument: true,

src/components/form-datepicker/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@
6969
"prop": "direction",
7070
"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"
7171
},
72+
{
73+
"prop": "buttonOnly",
74+
"version": "2.7.0",
75+
"description": "Renders the datepicker as a dropdown button instead of a form-control"
76+
},
77+
{
78+
"prop": "buttonVariant",
79+
"version": "2.7.0",
80+
"description": "The button variant to use when in `button-only` mode. Has no effect if prop `button-only` is not set"
81+
},
7282
{
7383
"prop": "calendarWidth",
7484
"version": "2.6.0",

src/components/form-timepicker/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,60 @@ control the positioning of the popup calendar.
208208
Refer to the [`<b-dropdown>` documentation](/docs/components/dropdown) for details on the effects
209209
and usage of these props.
210210

211+
### Button only mode
212+
213+
<span class="badge badge-info small">v2.7.0+</span>
214+
215+
Fancy just a button that launches the timepicker dialog, or want to provide your own optional text
216+
input field? Use the `button-only` prop to render the timepicker as a dropdown button. The formatted
217+
time label will be rendered with the class `sr-only` (available only to screen readers).
218+
219+
In the following simple example, we are placing the timepicker (button only mode) as an append to a
220+
`<b-input-group>`:
221+
222+
```html
223+
<template>
224+
<div>
225+
<label for="example-input">Choose a time</label>
226+
<b-input-group class="mb-3">
227+
<b-form-input
228+
id="example-input"
229+
v-model="value"
230+
type="text"
231+
placeholder="HH:mm:ss"
232+
></b-form-input>
233+
<b-input-group-append>
234+
<b-form-timepicker
235+
v-model="value"
236+
button-only
237+
right
238+
show-seconds
239+
:hour12="false"
240+
locale="en-US"
241+
aria-controls="example-input"
242+
></b-form-timepicker>
243+
</b-input-group-append">
244+
</b-input-group>
245+
<p>Value: '{{ value }}'</p>
246+
</div>
247+
</template>
248+
249+
<script>
250+
export default {
251+
data() {
252+
return {
253+
value: ''
254+
}
255+
}
256+
}
257+
</script>
258+
259+
<!-- b-form-timepicker-button-only.vue -->
260+
```
261+
262+
Control the size of the button via the `size` prop, and the button variant via the `button-variant`
263+
prop.
264+
211265
## Internationalization
212266
213267
Internationalization of the time interface is provided via

src/components/form-timepicker/form-timepicker.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ const propsMixin = {
9090
type: [Number, String],
9191
default: 1
9292
},
93+
buttonOnly: {
94+
type: Boolean,
95+
default: false
96+
},
97+
buttonVariant: {
98+
// Applicable in button only mode
99+
type: String,
100+
default: 'secondary'
101+
},
93102
nowButton: {
94103
type: Boolean,
95104
default: false
@@ -240,7 +249,12 @@ export const BFormTimepicker = /*#__PURE__*/ Vue.extend({
240249
this.localHMS = newVal || ''
241250
},
242251
localHMS(newVal) {
243-
this.$emit('input', newVal || '')
252+
// We only update hte v-model value when the timepicker
253+
// is open, to prevent cursor jumps when bound to a
254+
// text input in button only mode
255+
if (this.isVisible) {
256+
this.$emit('input', newVal || '')
257+
}
244258
}
245259
},
246260
methods: {

src/components/form-timepicker/form-timepicker.spec.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ describe('form-timepicker', () => {
4141
await waitRAF()
4242

4343
expect(wrapper.classes()).toContain('b-form-timepicker')
44+
expect(wrapper.classes()).toContain('b-form-btn-label-control')
4445
expect(wrapper.classes()).toContain('form-control')
4546
expect(wrapper.classes()).toContain('dropdown')
4647
expect(wrapper.classes()).not.toContain('show')
48+
expect(wrapper.classes()).not.toContain('btn-group')
4749
expect(wrapper.attributes('role')).toEqual('group')
4850

4951
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
@@ -66,6 +68,49 @@ describe('form-timepicker', () => {
6668
wrapper.destroy()
6769
})
6870

71+
it('has expected default structure when button-only is true', async () => {
72+
const wrapper = mount(BFormTimepicker, {
73+
attachToDocument: true,
74+
propsData: {
75+
id: 'test-button-only',
76+
buttonOnly: true
77+
}
78+
})
79+
80+
expect(wrapper.isVueInstance()).toBe(true)
81+
expect(wrapper.is('div')).toBe(true)
82+
await waitNT(wrapper.vm)
83+
await waitRAF()
84+
85+
expect(wrapper.classes()).toContain('b-form-timepicker')
86+
expect(wrapper.classes()).not.toContain('b-form-btn-label-control')
87+
expect(wrapper.classes()).not.toContain('form-control')
88+
expect(wrapper.classes()).toContain('dropdown')
89+
expect(wrapper.classes()).not.toContain('show')
90+
expect(wrapper.classes()).toContain('btn-group')
91+
expect(wrapper.attributes('role')).not.toEqual('group')
92+
93+
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
94+
expect(wrapper.find('.dropdown-menu').classes()).not.toContain('show')
95+
expect(wrapper.find('.dropdown-menu').attributes('role')).toEqual('dialog')
96+
expect(wrapper.find('.dropdown-menu').attributes('aria-modal')).toEqual('false')
97+
98+
expect(wrapper.find('label.form-control').exists()).toBe(true)
99+
expect(wrapper.find('label.form-control').attributes('for')).toEqual('test-button-only')
100+
expect(wrapper.find('label.form-control').text()).toContain('No time selected')
101+
expect(wrapper.find('label.form-control').classes()).toContain('sr-only')
102+
103+
expect(wrapper.find('input[type="hidden"]').exists()).toBe(false)
104+
105+
const $btn = wrapper.find('button#test-button-only')
106+
expect($btn.exists()).toBe(true)
107+
expect($btn.attributes('aria-haspopup')).toEqual('dialog')
108+
expect($btn.attributes('aria-expanded')).toEqual('false')
109+
expect($btn.find('svg.bi-clock').exists()).toBe(true)
110+
111+
wrapper.destroy()
112+
})
113+
69114
it('renders hidden input when name prop is set', async () => {
70115
const wrapper = mount(BFormTimepicker, {
71116
attachToDocument: true,

src/components/form-timepicker/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@
6262
"prop": "hideHeader",
6363
"description": "When set, visually hides the selected date header"
6464
},
65+
{
66+
"prop": "buttonOnly",
67+
"version": "2.7.0",
68+
"description": "Renders the datepicker as a dropdown button instead of a form-control"
69+
},
70+
{
71+
"prop": "buttonVariant",
72+
"version": "2.7.0",
73+
"description": "The button variant to use when in `button-only` mode. Has no effect if prop `button-only` is not set"
74+
},
6575
{
6676
"prop": "menuClass",
6777
"description": "Class (or classes) to apply to to popup menu wrapper"

0 commit comments

Comments
 (0)