From c61d221c0d84f4583f682d368b189b27932f73c8 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Sun, 9 Dec 2018 18:44:10 -0400 Subject: [PATCH 01/18] feat(form-group): Add multiple breakpoint support for label (Closes #2230) Adds support for multiple breakpoint specific label columns and label alignment Also removes the assumption that the user is using the default of 12 columns (Closes #2230) --- src/components/form-group/form-group.js | 494 ++++++++++++++---------- 1 file changed, 284 insertions(+), 210 deletions(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index d4b4d228eaf..6d13a1015b3 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -1,182 +1,230 @@ -import warn from '../../utils/warn' -import stripScripts from '../../utils/strip-scripts' -import { select, selectAll, isVisible, setAttr, removeAttr, getAttr } from '../../utils/dom' +// Mixins import idMixin from '../../mixins/id' import formStateMixin from '../../mixins/form-state' +// Utils +import upperFirst from '../../utils/upper-first' +import memoize from '../../utils/memoize' +import { select, selectAll, isVisible, setAttr, removeAttr, getAttr } from '../../utils/dom' +import { arrayIncludes } from '../../utils/array' +import { keys } from '../../utils/object' +// Sub components import bFormRow from '../layout/form-row' +import bCol from '../layout/col' import bFormText from '../form/form-text' import bFormInvalidFeedback from '../form/form-invalid-feedback' import bFormValidFeedback from '../form/form-valid-feedback' -// Selector for finding firt input in the form-group +// Selector for finding first input in the form-group const SELECTOR = 'input:not(:disabled),textarea:not(:disabled),select:not(:disabled)' -export default { - mixins: [ idMixin, formStateMixin ], - components: { bFormRow, bFormText, bFormInvalidFeedback, bFormValidFeedback }, - render (h) { - const $slots = this.$slots +// Breakpoint names for label-cols and label-align props +const BREAKPOINTS = ['', 'sm', 'md', 'lg', 'xl'] - // Label / Legend - let legend = h(false) - if (this.hasLabel) { - let children = $slots['label'] - const legendTag = this.labelFor ? 'label' : 'legend' - const legendDomProps = children ? {} : { innerHTML: stripScripts(this.label) } - const legendAttrs = { id: this.labelId, for: this.labelFor || null } - const legendClick = (this.labelFor || this.labelSrOnly) ? {} : { click: this.legendClick } - if (this.horizontal) { - // Horizontal layout with label - if (this.labelSrOnly) { - // SR Only we wrap label/legend in a div to preserve layout - children = h( - legendTag, - { class: [ 'sr-only' ], attrs: legendAttrs, domProps: legendDomProps }, - children - ) - legend = h('div', { class: this.labelLayoutClasses }, [ children ]) - } else { - legend = h( - legendTag, - { - class: [ this.labelLayoutClasses, this.labelClasses ], - attrs: legendAttrs, - domProps: legendDomProps, - on: legendClick - }, - children - ) - } - } else { - // Vertical layout with label - legend = h( - legendTag, - { - class: this.labelSrOnly ? [ 'sr-only' ] : this.labelClasses, - attrs: legendAttrs, - domProps: legendDomProps, - on: legendClick - }, - children - ) - } - } else if (this.horizontal) { - // No label but has horizontal layout, so we need a spacer element for layout - legend = h('div', { class: this.labelLayoutClasses }) - } +// Memoize this function to return cached values to save time in computed functions +const makePropName = memoize((bp = '', prefix) => { + return `${prefix}${upperFirst(bp)}` +}) - // Invalid feeback text (explicitly hidden if state is valid) - let invalidFeedback = h(false) - if (this.hasInvalidFeedback) { - let domProps = {} - if (!$slots['invalid-feedback'] && !$slots['feedback']) { - domProps = { innerHTML: stripScripts(this.invalidFeedback || this.feedback || '') } - } - invalidFeedback = h( - 'b-form-invalid-feedback', - { - props: { - id: this.invalidFeedbackId, - forceShow: this.computedState === false, - tooltip: this.tooltip - }, - attrs: { - role: 'alert', - 'aria-live': 'assertive', - 'aria-atomic': 'true' - }, - domProps: domProps +// Generate the labelCol breakpoint props +const bpLabelColProps = BREAKPOINTS.reduce((props, bp) => { + // label-cols, label-cols-sm, label-cols-md, ... + props[makePropName(bp, 'labelCols')] = { + type: bp === '' ? [Number, String] : [Boolean, Number, String], + default: null + } +}, {}) + +// Generate the labelAlign breakpoint props +const bpLabelAlignProps = BREAKPOINTS.reduce((props, bp) => { + // label-align, label-align-sm, label-align-md, ... + props[makePropName(bp, 'labelAlign')] = { + type: String, + default: null + } +}, {}) + +// render helper functions (here rather than polluting the instance with more methods) +function renderInvalidFeedback (h, ctx) { + let content = ctx.$slots['invalid-feedback'] || ctx.invalidFeedback + let invalidFeedback = h(false) + if (content) { + invalidFeedback = h( + 'b-form-invalid-feedback', + { + props: { + id: ctx.invalidFeedbackId, + // If state is explicitly false, always show the feedback + forceShow: ctx.computedState === false, + tooltip: ctx.tooltip }, - $slots['invalid-feedback'] || $slots['feedback'] - ) - } + attrs: { + tabindex: content ? '-1' : null, + role: 'alert', + 'aria-live': 'assertive', + 'aria-atomic': 'true' + } + }, + [content] + ) + } + return invalidFeedback +} - // Valid feeback text (explicitly hidden if state is invalid) - let validFeedback = h(false) - if (this.hasValidFeedback) { - const domProps = $slots['valid-feedback'] ? {} : { innerHTML: stripScripts(this.validFeedback || '') } - validFeedback = h( - 'b-form-valid-feedback', - { - props: { - id: this.validFeedbackId, - forceShow: this.computedState === true, - tooltip: this.tooltip - }, - attrs: { - role: 'alert', - 'aria-live': 'assertive', - 'aria-atomic': 'true' - }, - domProps: domProps +function renderValidFeedback (h, ctx) { + const content = ctx.$slots['valid-feedback'] || ctx.validFeedback + let validFeedback = h(false) + if (content) { + validFeedback = h( + 'b-form-valid-feedback', + { + props: { + id: ctx.validFeedbackId, + // If state is explicitly true, always show the feedback + forceShow: ctx.computedState === true, + tooltip: ctx.tooltip }, - $slots['valid-feedback'] - ) - } + attrs: { + tabindex: '-1', + role: 'alert', + 'aria-live': 'assertive', + 'aria-atomic': 'true' + } + }, + [content] + ) + } + return validFeedback +} - // Form help text (description) - let description = h(false) - if (this.hasDescription) { - const domProps = $slots['description'] ? {} : { innerHTML: stripScripts(this.description || '') } - description = h( - 'b-form-text', - { attrs: { id: this.descriptionId }, domProps: domProps }, - $slots['description'] - ) - } +function renderHelpText (h, ctx) { + // Form help text (description) + const content = ctx.$slots['description'] || ctx.description + let description = h(false) + if (content) { + description = h( + 'b-form-text', + { + attrs: { + id: ctx.descriptionId, + tabindex: '-1' + } + }, + [content] + ) + } + return description +} - // Build content layout - const content = h( - 'div', +function renderLabel (h, ctx) { + // render label/legend + const content = ctx.$slots['label'] || ctx.label + let label = h(false) + if (content) { + const labelFor = ctx.labelFor + const isLegend = !labelFor + const isHorizontal = ctx.isHorizontal + const isSrOnly = ctx.labelSrOnly + const on = {} + if (isLegend && !isSrOnly) { + // Add the legend click handler + on.click = ctx.legendClick + } + label = h( + isLegend ? 'legend' : 'label', { - ref: 'content', - class: this.inputLayoutClasses, - attrs: this.labelFor ? {} : { role: 'group', 'aria-labelledby': this.labelId } + on, + attrs: { + id: ctx.labelId, + for: labelFor || null, + // We add a tab index to legend so that screen readers will + // properly read the aria-labelledby in IE. + tabindex: isLegend ? '-1' : null + }, + class: [ + // when horizontal or a legend is rendered add col-form-label for correct sizing + isHorizontal || isLegend ? 'col-form-label' : '', + // Emulate label padding top of 0 on legend when not horizontal + !isHorizontal && isLegend ? 'pt-0' : '', + isSrOnly ? 'sr-only' : '', + ctx.size ? `col-form-label-${ctx.size}` : '', + ctx.labelAlignClasses, + ctx.labelClass + ] }, - [ $slots['default'], invalidFeedback, validFeedback, description ] + [ content ] ) + } + return label +} - // Generate main form-group wrapper - return h( - this.labelFor ? 'div' : 'fieldset', +// bFormGroup +export default { + mixins: [ + idMixin, + formStateMixin + ], + components: { + bFormRow, + bCol, + bFormInvalidFeedback, + bFormValidFeedback, + bFormText + }, + render (h) { + const isFieldset = !this.labelFor + const isHorizontal = this.isHorizontal + // Generate the label col + const label = h( + isHorizontal ? 'b-col' : 'div', + { props: this.labelColProps }, + [ renderLabel(h, this) ] + ) + // Generate the content col + const content = h( + isHorizontal ? 'b-col' : 'div', { - class: this.groupClasses, + ref: 'content', attrs: { - id: this.safeId(), - disabled: this.labelFor ? null : this.disabled, - role: 'group', - 'aria-invalid': this.computedState === false ? 'true' : null, - 'aria-labelledby': this.labelId, - 'aria-describedby': this.labelFor ? null : this.describedByIds + tabindex: isFieldset ? '-1' : null, + role: isFieldset ? 'group' : null, + 'aria-labelledby': isFieldset ? this.labelId : null, + 'aria-describedby': isFieldset ? this.ariaDescribedBy : null } }, - this.horizontal ? [ h('b-form-row', {}, [ legend, content ]) ] : [ legend, content ] + [ + this.$slots['default'] || h(false), + renderInvalidFeedback(h, this), + renderValidFeedback(h, this), + renderHelpText(h, this) + ] + ) + // Create the form-group + const data = { + staticClass: 'form-group b-form-group', + class: [ + this.validated ? 'was-validated' : null, + this.stateClass // from form-state mixin + ], + attrs: { + id: this.safeId(), + disabled: isFieldset ? this.disabled : null, + role: isFieldset ? null : 'group', + 'aria-invalid': this.computedState === false ? 'true' : null, + 'aria-labelledby': this.labelId || null, + 'aria-describedby': this.ariaDescribedBy || null + } + } + // Return it wrapped in a form-group. + // Note: fieldsets do not support adding `row` or `form-row` directly to them + // due to browser specific render issues, so we move the form-row to an + // inner wrapper div when horizontal + return h( + isFieldset ? 'fieldset' : (isHorizontal ? 'b-form-row' : 'div'), + data, + isHorizontal && isFieldset ? [h('b-form-row', {}, [label, content])] : [label, content] ) }, props: { - horizontal: { - type: Boolean, - default: false - }, - labelCols: { - type: [Number, String], - default: 3, - validator (value) { - if (Number(value) >= 1 && Number(value) <= 11) { - return true - } - warn('b-form-group: label-cols must be a value between 1 and 11') - return false - } - }, - breakpoint: { - type: String, - default: 'sm' - }, - labelTextAlign: { - type: String, - default: null - }, label: { type: String, default: null @@ -194,7 +242,7 @@ export default { default: false }, labelClass: { - type: [String, Array], + type: [String, Array, Object], default: null }, description: { @@ -205,16 +253,12 @@ export default { type: String, default: null }, - feedback: { - // Deprecated in favor of invalid-feedback - type: String, - default: null - }, validFeedback: { type: String, default: null }, tooltip: { + // Enable tooltip style feedback type: Boolean, default: false }, @@ -225,70 +269,90 @@ export default { disabled: { type: Boolean, default: false + }, + // label-cols prop and all label-cols-{bp} props + ...bpLabelColProps, + // label-align prop and all label-align-{bp} props + ...bpLabelAlignProps, + horizontal: { + // Deprecated + type: Boolean, + default: false + }, + breakpoint: { + // Deprecated + type: String, + default: null // legacy value 'sm' } }, computed: { - groupClasses () { - return [ - 'b-form-group', - 'form-group', - this.validated ? 'was-validated' : null, - this.stateClass - ] - }, - labelClasses () { - return [ - 'col-form-label', - this.labelSize ? `col-form-label-${this.labelSize}` : null, - this.labelTextAlign ? `text-${this.labelTextAlign}` : null, - this.horizontal ? null : 'pt-0', - this.labelClass - ] - }, - labelLayoutClasses () { - return [ - this.horizontal ? `col-${this.breakpoint}-${this.labelCols}` : null - ] - }, - inputLayoutClasses () { - return [ - this.horizontal ? `col-${this.breakpoint}-${12 - Number(this.labelCols)}` : null, - this.tooltip ? 'position-relative' : null - ] - }, - hasLabel () { - return this.label || this.$slots['label'] - }, - hasDescription () { - return this.description || this.$slots['description'] - }, - hasInvalidFeedback () { - if (this.computedState === true) { - // If the form-group state is explicityly valid, we return false - return false + labelColProps () { + const props = {} + if (this.horizontal) { + // Deprecated setting of horizontal prop + // Legacy default is breakpoint sm and cols 3 + const bp = this.breakpoint || 'sm' + const cols = parseInt(this.labelCols, 10) || 3 + props[bp] = cols > 0 ? cols : 3 } - return this.invalidFeedback || this.feedback || this.$slots['invalid-feedback'] || this.$slots['feedback'] + BREAKPOINTS.forEach(bp => { + // Assemble the label column breakpoint props + let propVal = this[makePropName(bp, 'labelCols')] + propVal = propVal === '' ? Boolean(bp) : (propVal || false) + if (typeof propVal !== 'boolean') { + // Convert to column size + propVal = parseInt(propVal, 10) || 0 + // Ensure column size is greater than 0 + propVal = propVal > 0 ? propVal : false + } + if (propVal) { + props[bp || 'cols'] = propVal + } + }) + return props }, - hasValidFeedback () { - if (this.computedState === false) { - // If the form-group state is explicityly invalid, we return false - return false - } - return this.validFeedback || this.$slots['valid-feedback'] + labelAlignClasses () { + const classes = [] + BREAKPOINTS.forEach(bp => { + // assemble the label column breakpoint align classes + const propVal = this[makePropName(bp, 'labelAlign')] || null + if (propVal) { + const className = bp ? `text-${bp}-${propVal}` : `text-${propVal}` + classes.push(className) + } + }) + return classes + }, + isHorizontal () { + // Determine if the resultant form-group will be rendered + // horizontal (meaning it has label-col breakpoints) + return keys(this.colLabelProps).length > 0 }, labelId () { - return this.hasLabel ? this.safeId('_BV_label_') : null + return (this.$slots['label'] || this.label) ? this.safeId('_BV_label_') : null }, descriptionId () { - return this.hasDescription ? this.safeId('_BV_description_') : null + return (this.$slots['description'] || this.description) ? this.safeId('_BV_description_') : null + }, + hasInvalidFeedback () { + // used for computing aria-describedby + const $slots = this.$slots + return this.computedState === false && ($slots['invalid-feedback'] || this.invalidFeedback) }, invalidFeedbackId () { return this.hasInvalidFeedback ? this.safeId('_BV_feedback_invalid_') : null }, + hasValidFeedback () { + // used for computing aria-describedby + return this.computedState === true && (this.$slots['valid-feedback'] || this.validFeedback) + }, validFeedbackId () { return this.hasValidFeedback ? this.safeId('_BV_feedback_valid_') : null }, describedByIds () { + // Screen readers will read out any content linked to by aria-describedby + // even if the content is hidden with 'display: none', hence we only include + // feedback IDs if the form-group's state is explicitly valid or invalid. return [ this.descriptionId, this.invalidFeedbackId, @@ -305,15 +369,24 @@ export default { }, methods: { legendClick (evt) { + if (this.labelFor) { + // don't do anything if labelFor is set + return + } const tagName = evt.target ? evt.target.tagName : '' - if (/^(input|select|textarea|label)$/i.test(tagName)) { - // If clicked an input inside legend, we just let the default happen + if (/^(input|select|textarea|label|button|a)$/i.test(tagName)) { + // If clicked an interactive element inside legend, we just let the default happen return } - // Focus the first non-disabled visible input when the legend element is clicked const inputs = selectAll(SELECTOR, this.$refs.content).filter(isVisible) - if (inputs[0] && inputs[0].focus) { + if (inputs && inputs.length === 1 && inputs[0].focus) { + // if only a single input, focus it inputs[0].focus() + } else { + // Focus the content group + if (this.$refs.content && this.$refs.content.focus) { + this.$refs.content.focus() + } } }, setInputDescribedBy (add, remove) { @@ -326,10 +399,11 @@ export default { let ids = (getAttr(input, adb) || '').split(/\s+/) remove = (remove || '').split(/\s+/) // Update ID list, preserving any original IDs - ids = ids.filter(id => remove.indexOf(id) === -1).concat(add || '').join(' ').trim() + ids = ids.filter(id => !arrayIncludes(remove, id)).concat(add || '').join(' ').trim() if (ids) { setAttr(input, adb, ids) } else { + // No IDs, so remove the attribute removeAttr(input, adb) } } From ff39fd3cf5c69a5069b5c84cf14caf25c74ec2f2 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Sun, 9 Dec 2018 18:55:06 -0400 Subject: [PATCH 02/18] Update form-group.js --- src/components/form-group/form-group.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index 6d13a1015b3..4ef8d20017e 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -6,7 +6,7 @@ import upperFirst from '../../utils/upper-first' import memoize from '../../utils/memoize' import { select, selectAll, isVisible, setAttr, removeAttr, getAttr } from '../../utils/dom' import { arrayIncludes } from '../../utils/array' -import { keys } from '../../utils/object' +import { keys, create } from '../../utils/object' // Sub components import bFormRow from '../layout/form-row' import bCol from '../layout/col' @@ -32,7 +32,7 @@ const bpLabelColProps = BREAKPOINTS.reduce((props, bp) => { type: bp === '' ? [Number, String] : [Boolean, Number, String], default: null } -}, {}) +}, create(null)) // Generate the labelAlign breakpoint props const bpLabelAlignProps = BREAKPOINTS.reduce((props, bp) => { @@ -41,7 +41,7 @@ const bpLabelAlignProps = BREAKPOINTS.reduce((props, bp) => { type: String, default: null } -}, {}) +}, create(null)) // render helper functions (here rather than polluting the instance with more methods) function renderInvalidFeedback (h, ctx) { From a4be42b5ca7b2c92e98b5e942b17b503fd72c41d Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Sun, 9 Dec 2018 19:02:58 -0400 Subject: [PATCH 03/18] Update form-group.js --- src/components/form-group/form-group.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index 4ef8d20017e..7dd9762d5bd 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -32,6 +32,7 @@ const bpLabelColProps = BREAKPOINTS.reduce((props, bp) => { type: bp === '' ? [Number, String] : [Boolean, Number, String], default: null } + return props }, create(null)) // Generate the labelAlign breakpoint props @@ -41,6 +42,7 @@ const bpLabelAlignProps = BREAKPOINTS.reduce((props, bp) => { type: String, default: null } + return props }, create(null)) // render helper functions (here rather than polluting the instance with more methods) From 9f10c21bfa575564602d3c90fd4ad63512f50117 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Sun, 9 Dec 2018 19:08:09 -0400 Subject: [PATCH 04/18] Update form-group.html --- src/components/form-group/fixtures/form-group.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/form-group/fixtures/form-group.html b/src/components/form-group/fixtures/form-group.html index c164a8f8307..48674210de7 100755 --- a/src/components/form-group/fixtures/form-group.html +++ b/src/components/form-group/fixtures/form-group.html @@ -11,9 +11,12 @@ Date: Sun, 9 Dec 2018 19:14:31 -0400 Subject: [PATCH 05/18] Update package.json --- src/components/form-group/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/form-group/package.json b/src/components/form-group/package.json index cf7f2cd079f..50566b5b32b 100755 --- a/src/components/form-group/package.json +++ b/src/components/form-group/package.json @@ -21,10 +21,6 @@ { "name": "valid-feedback", "description": "Content to place in the valid feedback area" - }, - { - "name": "feedback", - "description": "Content to place in the invalid feedback area. Deprecated: use 'invalid-feedback' slot instead" } ] } From d588818154a9d44b0aac25a2e65a8bd862d31533 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Sun, 9 Dec 2018 19:19:14 -0400 Subject: [PATCH 06/18] Update form-group.js --- src/components/form-group/form-group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index 7dd9762d5bd..81d23f3c16e 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -328,7 +328,7 @@ export default { isHorizontal () { // Determine if the resultant form-group will be rendered // horizontal (meaning it has label-col breakpoints) - return keys(this.colLabelProps).length > 0 + return keys(this.labelColProps).length > 0 }, labelId () { return (this.$slots['label'] || this.label) ? this.safeId('_BV_label_') : null From 2673c7ae2474b5b567260bc5ddbc0754b0718012 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Sun, 9 Dec 2018 19:24:30 -0400 Subject: [PATCH 07/18] Update form-group.html --- .../form-group/fixtures/form-group.html | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/form-group/fixtures/form-group.html b/src/components/form-group/fixtures/form-group.html index 48674210de7..556f1532632 100755 --- a/src/components/form-group/fixtures/form-group.html +++ b/src/components/form-group/fixtures/form-group.html @@ -49,4 +49,24 @@ + + + + + + + + From f358d592d44af966ff55349a0f58f23e1c73fd62 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Sun, 9 Dec 2018 20:09:03 -0400 Subject: [PATCH 08/18] fix label-size --- src/components/form-group/form-group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index 81d23f3c16e..a948a9ab88b 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -148,7 +148,7 @@ function renderLabel (h, ctx) { // Emulate label padding top of 0 on legend when not horizontal !isHorizontal && isLegend ? 'pt-0' : '', isSrOnly ? 'sr-only' : '', - ctx.size ? `col-form-label-${ctx.size}` : '', + ctx.labelSize ? `col-form-label-${ctx.labelSize}` : '', ctx.labelAlignClasses, ctx.labelClass ] From d9ed42b9c44ea81e664b831391ad37c62c7e2a37 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 10 Dec 2018 00:18:30 -0400 Subject: [PATCH 09/18] Update form-group.js --- src/components/form-group/form-group.js | 77 +++++++++++++------------ 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index a948a9ab88b..e4a0bc6d042 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -118,45 +118,59 @@ function renderHelpText (h, ctx) { } function renderLabel (h, ctx) { - // render label/legend + // render label/legend inside b-col if necessary const content = ctx.$slots['label'] || ctx.label - let label = h(false) - if (content) { - const labelFor = ctx.labelFor - const isLegend = !labelFor - const isHorizontal = ctx.isHorizontal - const isSrOnly = ctx.labelSrOnly - const on = {} - if (isLegend && !isSrOnly) { - // Add the legend click handler - on.click = ctx.legendClick + const labelFor = ctx.labelFor + const isLegend = !labelFor + const isHorizontal = ctx.isHorizontal + const labelTag = isLegend ? 'legend' : 'label' + if (!content && !isHorizontal) { + return h(false) + } else if (ctx.labelSrOnly) { + let label = h(false) + if (content) { + label = h( + labelTag, + { + class: 'sr-only', + attrs: { id: ctx.labelId, for: labelFor || null } + }, + [content] + ) } - label = h( - isLegend ? 'legend' : 'label', + return h( + isHorizontal ? 'b-col' : 'div', + { props: isHorizontal ? ctx.labelColProps : {} }, + [label] + ) + } else { + if + return h( + isHorizontal ? 'b-col' : labelTag, { - on, + on: isLegend ? { click: ctx.legendClick } : {}, + props: isHorizontal ? { tag: labelTag, ...ctx.labelColProps } : {}, attrs: { id: ctx.labelId, for: labelFor || null, - // We add a tab index to legend so that screen readers will - // properly read the aria-labelledby in IE. + // We add a tab index to legend so that screen readers will properly read the aria-labelledby in IE. tabindex: isLegend ? '-1' : null }, class: [ - // when horizontal or a legend is rendered add col-form-label for correct sizing + // When horizontal or if a legend is rendered, add col-form-label for correct sizing + // as Boostrap has inconsitent font styling for legend in non-horiontal form-groups. + // see: https://github.com/twbs/bootstrap/issues/27805 isHorizontal || isLegend ? 'col-form-label' : '', // Emulate label padding top of 0 on legend when not horizontal !isHorizontal && isLegend ? 'pt-0' : '', - isSrOnly ? 'sr-only' : '', ctx.labelSize ? `col-form-label-${ctx.labelSize}` : '', ctx.labelAlignClasses, ctx.labelClass ] }, - [ content ] + [content] ) } - return label } // bFormGroup @@ -175,13 +189,9 @@ export default { render (h) { const isFieldset = !this.labelFor const isHorizontal = this.isHorizontal - // Generate the label col - const label = h( - isHorizontal ? 'b-col' : 'div', - { props: this.labelColProps }, - [ renderLabel(h, this) ] - ) - // Generate the content col + // Generate the label + const label = renderLabel(h, this) + // Generate the content const content = h( isHorizontal ? 'b-col' : 'div', { @@ -202,10 +212,10 @@ export default { ) // Create the form-group const data = { - staticClass: 'form-group b-form-group', + staticClass: 'form-group', class: [ this.validated ? 'was-validated' : null, - this.stateClass // from form-state mixin + this.stateClass ], attrs: { id: this.safeId(), @@ -219,7 +229,7 @@ export default { // Return it wrapped in a form-group. // Note: fieldsets do not support adding `row` or `form-row` directly to them // due to browser specific render issues, so we move the form-row to an - // inner wrapper div when horizontal + // inner wrapper div when horizontal and using a fieldset return h( isFieldset ? 'fieldset' : (isHorizontal ? 'b-form-row' : 'div'), data, @@ -382,13 +392,8 @@ export default { } const inputs = selectAll(SELECTOR, this.$refs.content).filter(isVisible) if (inputs && inputs.length === 1 && inputs[0].focus) { - // if only a single input, focus it + // if only a single input, focus it, emulating label behaviour inputs[0].focus() - } else { - // Focus the content group - if (this.$refs.content && this.$refs.content.focus) { - this.$refs.content.focus() - } } }, setInputDescribedBy (add, remove) { From b31520c311ec7d41f8fb80f1a0372c053ce9f2f8 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 10 Dec 2018 02:00:12 -0400 Subject: [PATCH 10/18] support boolean values for xs brealpoint --- src/components/form-group/form-group.js | 49 +++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index e4a0bc6d042..7f163405f12 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -4,6 +4,7 @@ import formStateMixin from '../../mixins/form-state' // Utils import upperFirst from '../../utils/upper-first' import memoize from '../../utils/memoize' +import warn from '../../utils/warn' import { select, selectAll, isVisible, setAttr, removeAttr, getAttr } from '../../utils/dom' import { arrayIncludes } from '../../utils/array' import { keys, create } from '../../utils/object' @@ -21,25 +22,25 @@ const SELECTOR = 'input:not(:disabled),textarea:not(:disabled),select:not(:disab const BREAKPOINTS = ['', 'sm', 'md', 'lg', 'xl'] // Memoize this function to return cached values to save time in computed functions -const makePropName = memoize((bp = '', prefix) => { - return `${prefix}${upperFirst(bp)}` +const makePropName = memoize((breakpoint = '', prefix) => { + return `${prefix}${upperFirst(breakpoint)}` }) // Generate the labelCol breakpoint props -const bpLabelColProps = BREAKPOINTS.reduce((props, bp) => { +const bpLabelColProps = BREAKPOINTS.reduce((props, breakpoint) => { // label-cols, label-cols-sm, label-cols-md, ... - props[makePropName(bp, 'labelCols')] = { - type: bp === '' ? [Number, String] : [Boolean, Number, String], - default: null + props[makePropName(breakpoint, 'labelCols')] = { + type: [Number, String, Boolean], + default: breakpoint ? false : null } return props }, create(null)) // Generate the labelAlign breakpoint props -const bpLabelAlignProps = BREAKPOINTS.reduce((props, bp) => { +const bpLabelAlignProps = BREAKPOINTS.reduce((props, breakpoint) => { // label-align, label-align-sm, label-align-md, ... - props[makePropName(bp, 'labelAlign')] = { - type: String, + props[makePropName(breakpoint, 'labelAlign')] = { + type: String, // left, right, center default: null } return props @@ -292,7 +293,7 @@ export default { default: false }, breakpoint: { - // Deprecated + // Deprecated (ignored if horizontal is not true) type: String, default: null // legacy value 'sm' } @@ -301,35 +302,43 @@ export default { labelColProps () { const props = {} if (this.horizontal) { - // Deprecated setting of horizontal prop + // Deprecated setting of horizontal/breakpoint props + warn("b-form-group: Props 'horizontal' and 'breakpoint' are deprecated. Use 'label-cols(-{breakpoint})' props instead.") // Legacy default is breakpoint sm and cols 3 const bp = this.breakpoint || 'sm' const cols = parseInt(this.labelCols, 10) || 3 props[bp] = cols > 0 ? cols : 3 + // We then return the single breakpoint prop for legacy compatability + return props } - BREAKPOINTS.forEach(bp => { - // Assemble the label column breakpoint props - let propVal = this[makePropName(bp, 'labelCols')] - propVal = propVal === '' ? Boolean(bp) : (propVal || false) + BREAKPOINTS.forEach(breakpoint => { + // Grab the value if the label column breakpoint prop + let propVal = this[makePropName(breakpoint, 'labelCols')] + // Handle case where the prop's value is an empty string, which represents true + propVal = propVal === '' ? true : (propVal || false) if (typeof propVal !== 'boolean') { - // Convert to column size + // Convert to column size to number propVal = parseInt(propVal, 10) || 0 // Ensure column size is greater than 0 propVal = propVal > 0 ? propVal : false } if (propVal) { - props[bp || 'cols'] = propVal + // Add the prop to the list of props to give to b-col. + // if breakpoint is '' (labelCols=true), then we use the col prop to make equal width at xs + const bColPropName = breakpoint || (typeof propVal === 'boolean' ? 'col' : 'cols') + // Add it to the props + props[bColPropName] = propVal } }) return props }, labelAlignClasses () { const classes = [] - BREAKPOINTS.forEach(bp => { + BREAKPOINTS.forEach(breakpoint => { // assemble the label column breakpoint align classes - const propVal = this[makePropName(bp, 'labelAlign')] || null + const propVal = this[makePropName(breakpoint, 'labelAlign')] || null if (propVal) { - const className = bp ? `text-${bp}-${propVal}` : `text-${propVal}` + const className = breakpoint ? `text-${breakpoint}-${propVal}` : `text-${propVal}` classes.push(className) } }) From 3920c1ffbc805f486746ff8387a8e4796587cb27 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 10 Dec 2018 02:02:20 -0400 Subject: [PATCH 11/18] lint --- src/components/form-group/form-group.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index 7f163405f12..c92e8c63944 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -145,7 +145,6 @@ function renderLabel (h, ctx) { [label] ) } else { - if return h( isHorizontal ? 'b-col' : labelTag, { From c7f6f0b13d8a4d006450d9b2f9c304a0161f3640 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 10 Dec 2018 02:22:21 -0400 Subject: [PATCH 12/18] Update form-group.js --- src/components/form-group/form-group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index c92e8c63944..4410cfdd7ec 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -223,7 +223,7 @@ export default { role: isFieldset ? null : 'group', 'aria-invalid': this.computedState === false ? 'true' : null, 'aria-labelledby': this.labelId || null, - 'aria-describedby': this.ariaDescribedBy || null + 'aria-describedby': this.describedByIds || null } } // Return it wrapped in a form-group. From 9eecb8937bc6c99637519d4035303614b23784bd Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Tue, 11 Dec 2018 00:34:27 -0400 Subject: [PATCH 13/18] chore: merge in latest dev commits (#2262) * fix(dropdown): Menu focusout close handling (#2252) * feat(table): Selectable rows (fixes #1790) (#2260) * feat(modal): Make stackable optional (#2259) --- src/components/dropdown/dropdown.js | 1 - src/components/link/link.js | 3 +- src/components/modal/README.md | 5 +- src/components/modal/modal.js | 95 +++++++++++++------- src/components/nav/nav-item-dropdown.js | 1 - src/components/table/README.md | 95 +++++++++++++++++--- src/components/table/_table.scss | 6 ++ src/components/table/package.json | 10 +++ src/components/table/table.js | 113 ++++++++++++++++++++++-- src/mixins/click-out.js | 48 ++++++++++ src/mixins/dropdown.js | 101 +++++++++++---------- src/mixins/focus-in.js | 41 +++++++++ 12 files changed, 420 insertions(+), 99 deletions(-) create mode 100644 src/mixins/click-out.js create mode 100644 src/mixins/focus-in.js diff --git a/src/components/dropdown/dropdown.js b/src/components/dropdown/dropdown.js index 9a4370afa60..d9b1f8897b4 100644 --- a/src/components/dropdown/dropdown.js +++ b/src/components/dropdown/dropdown.js @@ -66,7 +66,6 @@ export default { }, on: { mouseover: this.onMouseOver, - focusout: this.onFocusOut, // focus out of menu keydown: this.onKeydown // tab, up, down, esc } }, diff --git a/src/components/link/link.js b/src/components/link/link.js index b28b6b5b3b1..6453302ccdd 100644 --- a/src/components/link/link.js +++ b/src/components/link/link.js @@ -155,14 +155,13 @@ function clickHandlerFactory ({ disabled, tag, href, suppliedHandler, parent }) // Kill the event loop attached to this specific EventTarget. e.stopImmediatePropagation() } else { - parent.$root.$emit('clicked::link', e) - if (isRouterLink && e.target.__vue__) { e.target.__vue__.$emit('click', e) } if (typeof suppliedHandler === 'function') { suppliedHandler(...arguments) } + parent.$root.$emit('clicked::link', e) } if ((!isRouterLink && href === '#') || disabled) { diff --git a/src/components/modal/README.md b/src/components/modal/README.md index 7538aba8b7c..675c1f189fb 100755 --- a/src/components/modal/README.md +++ b/src/components/modal/README.md @@ -439,10 +439,13 @@ prop to `true`. Set it to `false` to re-enable both buttons. ## Multiple modal support Unlike native Bootstrap V4, Bootstrap-Vue supports multiple modals opened at the same time. +To disable stacking for a specific modal, just set the prop `no-stacking` on the +`` component. This will hide the modal before another modal is shown. + ```html
Open First Modal - +

First Modal

Open Second Modal
diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index 4ec0c10f30c..6c15697a83f 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -9,20 +9,21 @@ import BvEvent from '../../utils/bv-event.class' import stripScripts from '../../utils/strip-scripts' import { - isVisible, - selectAll, - select, + addClass, contains, + eventOff, + eventOn, + getAttr, getBCR, getCS, - addClass, - removeClass, - setAttr, - removeAttr, - getAttr, hasAttr, - eventOn, - eventOff + hasClass, + isVisible, + removeAttr, + removeClass, + select, + selectAll, + setAttr } from '../../utils/dom' // Selectors for padding/margin adjustments @@ -328,6 +329,10 @@ export default { type: String, default: '' }, + noStacking: { + type: Boolean, + default: false + }, noFade: { type: Boolean, default: false @@ -555,21 +560,20 @@ export default { // Don't show if canceled return } - // Find the z-index to use - this.zIndex = getModalNextZIndex() - // Place modal in DOM if lazy - this.is_hidden = false - this.$nextTick(() => { - // We do this in nextTick to ensure the modal is in DOM first before we show it - this.is_visible = true - this.$emit('change', true) - // Observe changes in modal content and adjust if necessary - this._observer = observeDom( - this.$refs.content, - this.adjustDialog.bind(this), - OBSERVER_CONFIG - ) - }) + if (!this.noStacking) { + // Find the z-index to use + this.zIndex = getModalNextZIndex() + // Show the modal + this.doShow() + return + } + if (hasClass(document.body, 'modal-open')) { + // If another modal is already open, wait for it to close + this.$root.$once('bv::modal::hidden', this.doShow) + return + } + // Show the modal + this.doShow() }, hide (trigger) { if (!this.is_visible) { @@ -609,6 +613,22 @@ export default { this.is_visible = false this.$emit('change', false) }, + // Private method to finish showing modal + doShow () { + // Place modal in DOM if lazy + this.is_hidden = false + this.$nextTick(() => { + // We do this in nextTick to ensure the modal is in DOM first before we show it + this.is_visible = true + this.$emit('change', true) + // Observe changes in modal content and adjust if necessary + this._observer = observeDom( + this.$refs.content, + this.adjustDialog.bind(this), + OBSERVER_CONFIG + ) + }) + }, // Transition Handlers onBeforeEnter () { this.getScrollbarWidth() @@ -619,7 +639,7 @@ export default { this.setScrollbar() } this.adjustDialog() - addClass(document.body, 'modal-open') + this.setModalOpenClass(true) this.setResizeEvent(true) }, onEnter () { @@ -655,12 +675,12 @@ export default { const count = decrementModalOpenCount() if (count === 0) { this.resetScrollbar() - removeClass(document.body, 'modal-open') + this.setModalOpenClass(false) } this.setEnforceFocus(false) this.$nextTick(() => { this.is_hidden = this.lazy || false - this.zIndex = 0 + this.zIndex = ZINDEX_OFFSET this.returnFocusTo() const hiddenEvt = new BvEvent('hidden', { cancelable: false, @@ -719,7 +739,7 @@ export default { }, // Resize Listener setResizeEvent (on) { - ;['resize', 'orientationchange'].forEach(evtName => { + ['resize', 'orientationchange'].forEach(evtName => { if (on) { eventOn(window, evtName, this.adjustDialog) } else { @@ -749,6 +769,12 @@ export default { // Determine if we are the topmost visible modal this.isTop = this.zIndex >= getModalMaxZIndex() }, + modalListener (bvEvt) { + // If another modal opens, close this one + if (this.noStacking && bvEvt.vueTarget !== this) { + this.hide() + } + }, // Focus control handlers focusFirst () { // Don't try and focus if we are SSR @@ -792,6 +818,13 @@ export default { this.scrollbarWidth = getBCR(scrollDiv).width - scrollDiv.clientWidth document.body.removeChild(scrollDiv) }, + setModalOpenClass (open) { + if (open) { + addClass(document.body, 'modal-open') + } else { + removeClass(document.body, 'modal-open') + } + }, adjustDialog () { if (!this.is_visible) { return @@ -901,6 +934,8 @@ export default { this.listenOnRoot('bv::modal::shown', this.shownHandler) this.listenOnRoot('bv::hide::modal', this.hideHandler) this.listenOnRoot('bv::modal::hidden', this.hiddenHandler) + // Listen for bv:modal::show events, and close ourselves if the opening modal not us + this.listenOnRoot('bv::modal::show', this.modalListener) // Initially show modal? if (this.visible === true) { this.show() @@ -921,7 +956,7 @@ export default { const count = decrementModalOpenCount() if (count === 0) { // Re-adjust body/navbar/fixed padding/margins (as we were the last modal open) - removeClass(document.body, 'modal-open') + this.setModalOpenClass(false) this.resetScrollbar() this.resetDialogAdjustments() } diff --git a/src/components/nav/nav-item-dropdown.js b/src/components/nav/nav-item-dropdown.js index a954b11e220..5c171a995f2 100644 --- a/src/components/nav/nav-item-dropdown.js +++ b/src/components/nav/nav-item-dropdown.js @@ -36,7 +36,6 @@ export default { attrs: { 'aria-labelledby': this.safeId('_BV_button_') }, on: { mouseover: this.onMouseOver, - focusout: this.onFocusOut, // focus out of menu keydown: this.onKeydown // tab, up, down, esc } }, diff --git a/src/components/table/README.md b/src/components/table/README.md index db4d469bb0e..ff58e8cbc44 100755 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -334,14 +334,14 @@ fields: [ ```html
- - From 8da4392c92bef2d2284d493b7f1ab1655f64506c Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Tue, 11 Dec 2018 02:09:18 -0400 Subject: [PATCH 18/18] Update README.md --- src/components/table/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/table/README.md b/src/components/table/README.md index ff58e8cbc44..9428907ac9e 100755 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -917,15 +917,15 @@ event, passing a single argument which is the complete list of selected items. ```html @@ -1487,7 +1487,7 @@ functionality is non critical or can be provided via other means: - + @@ -1497,7 +1497,7 @@ functionality is non critical or can be provided via other means: - + @@ -1510,7 +1510,7 @@ functionality is non critical or can be provided via other means: - + @@ -1521,7 +1521,7 @@ functionality is non critical or can be provided via other means: - +