diff --git a/src/components/form-group/form-group.js b/src/components/form-group/form-group.js index f2224e465bc..3f5e25851be 100644 --- a/src/components/form-group/form-group.js +++ b/src/components/form-group/form-group.js @@ -30,147 +30,24 @@ import { BFormValidFeedback } from '../form/form-valid-feedback' // --- Constants --- // Selector for finding first input in the form-group -const SELECTOR = 'input:not([disabled]),textarea:not([disabled]),select:not([disabled])' +const INPUT_SELECTOR = 'input:not([disabled]),textarea:not([disabled]),select:not([disabled])' -// --- Render methods --- -const renderInvalidFeedback = (h, ctx) => { - const content = ctx.normalizeSlot('invalid-feedback') || ctx.invalidFeedback - let invalidFeedback = h() - if (content) { - invalidFeedback = h( - BFormInvalidFeedback, - { - props: { - id: ctx.invalidFeedbackId, - // If state is explicitly false, always show the feedback - state: ctx.computedState, - tooltip: ctx.tooltip, - ariaLive: ctx.feedbackAriaLive, - role: ctx.feedbackAriaLive ? 'alert' : null - }, - attrs: { tabindex: content ? '-1' : null } - }, - [content] - ) - } - return invalidFeedback -} - -const renderValidFeedback = (h, ctx) => { - const content = ctx.normalizeSlot('valid-feedback') || ctx.validFeedback - let validFeedback = h() - if (content) { - validFeedback = h( - BFormValidFeedback, - { - props: { - id: ctx.validFeedbackId, - // If state is explicitly true, always show the feedback - state: ctx.computedState, - tooltip: ctx.tooltip, - ariaLive: ctx.feedbackAriaLive, - role: ctx.feedbackAriaLive ? 'alert' : null - }, - attrs: { tabindex: content ? '-1' : null } - }, - [content] - ) - } - return validFeedback -} - -const renderHelpText = (h, ctx) => { - // Form help text (description) - const content = ctx.normalizeSlot(SLOT_NAME_DESCRIPTION) || ctx.description - let description = h() - if (content) { - description = h( - BFormText, - { - attrs: { - id: ctx.descriptionId, - tabindex: content ? '-1' : null - } - }, - [content] - ) - } - return description -} +// A list of interactive elements (tag names) inside ``'s legend +const LEGEND_INTERACTIVE_ELEMENTS = ['input', 'select', 'textarea', 'label', 'button', 'a'] -const renderLabel = (h, ctx) => { - // Render label/legend inside b-col if necessary - const content = ctx.normalizeSlot(SLOT_NAME_LABEL) || ctx.label - const labelFor = ctx.labelFor - const isLegend = !labelFor - const isHorizontal = ctx.isHorizontal - const labelTag = isLegend ? 'legend' : 'label' - if (!content && !isHorizontal) { - return h() - } else if (ctx.labelSrOnly) { - let label = h() - if (content) { - label = h( - labelTag, - { - class: 'sr-only', - attrs: { id: ctx.labelId, for: labelFor || null } - }, - [content] - ) - } - return h(isHorizontal ? BCol : 'div', { props: isHorizontal ? ctx.labelColProps : {} }, [label]) - } else { - return h( - isHorizontal ? BCol : labelTag, - { - 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. - tabindex: isLegend ? '-1' : null - }, - class: [ - // Hide the focus ring on the legend - isLegend ? 'bv-no-focus-ring' : '', - // When horizontal or if a legend is rendered, add col-form-label - // for correct sizing as Bootstrap has inconsistent font styling - // for legend in non-horizontal 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' : '', - // If not horizontal and not a legend, we add d-block to label - // so that label-align works - !isHorizontal && !isLegend ? 'd-block' : '', - ctx.labelSize ? `col-form-label-${ctx.labelSize}` : '', - ctx.labelAlignClasses, - ctx.labelClass - ] - }, - [content] - ) - } -} - -// -- BFormGroup Prop factory -- used for lazy generation of props +// -- BFormGroup prop factory -- used for lazy generation of props // Memoize this function to return cached values to // save time in computed functions -const makePropName = memoize((breakpoint = '', prefix) => { - return `${prefix}${upperFirst(breakpoint)}` -}) +const makePropName = memoize((breakpoint = '', prefix) => `${prefix}${upperFirst(breakpoint)}`) // BFormGroup prop generator for lazy generation of props const generateProps = () => { const CODE_BREAKPOINTS = getBreakpointsUpCached() - // Generate the labelCol breakpoint props + // Generate the `labelCol` breakpoint props const bpLabelColProps = CODE_BREAKPOINTS.reduce((props, breakpoint) => { - // i.e. label-cols, label-cols-sm, label-cols-md, ... + // i.e. 'label-cols', 'label-cols-sm', 'label-cols-md', ... props[makePropName(breakpoint, 'labelCols')] = { type: [Number, String, Boolean], default: breakpoint ? false : null @@ -178,9 +55,9 @@ const generateProps = () => { return props }, create(null)) - // Generate the labelAlign breakpoint props + // Generate the `labelAlign` breakpoint props const bpLabelAlignProps = CODE_BREAKPOINTS.reduce((props, breakpoint) => { - // label-align, label-align-sm, label-align-md, ... + // 'label-align', 'bel-align-sm', 'label-align-md', ... props[makePropName(breakpoint, 'labelAlign')] = { type: String // left, right, center // default: null @@ -253,12 +130,17 @@ export const BFormGroup = { mixins: [idMixin, formStateMixin, normalizeSlotMixin], get props() { // Allow props to be lazy evaled on first access and - // then they become a non-getter afterwards. + // then they become a non-getter afterwards // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get#Smart_self-overwriting_lazy_getters delete this.props // eslint-disable-next-line no-return-assign return (this.props = generateProps()) }, + data() { + return { + describedByIds: '' + } + }, computed: { labelColProps() { const props = {} @@ -266,21 +148,19 @@ export const BFormGroup = { // 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 + // which represents `true` propVal = propVal === '' ? true : propVal || false if (!isBoolean(propVal) && propVal !== 'auto') { // Convert to column size to number propVal = toInteger(propVal, 0) - // Ensure column size is greater than 0 + // Ensure column size is greater than `0` propVal = propVal > 0 ? propVal : false } if (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 || (isBoolean(propVal) ? 'col' : 'cols') - // Add it to the props - props[bColPropName] = propVal + // Add the prop to the list of props to give to `` + // If breakpoint is '' (`labelCols` is `true`), then we use the + // col prop to make equal width at 'xs' + props[breakpoint || (isBoolean(propVal) ? 'col' : 'cols')] = propVal } }) return props @@ -301,86 +181,47 @@ export const BFormGroup = { // Determine if the resultant form-group will be rendered // horizontal (meaning it has label-col breakpoints) return keys(this.labelColProps).length > 0 - }, - labelId() { - return this.hasNormalizedSlot(SLOT_NAME_LABEL) || this.label - ? this.safeId('_BV_label_') - : null - }, - descriptionId() { - return this.hasNormalizedSlot(SLOT_NAME_DESCRIPTION) || this.description - ? this.safeId('_BV_description_') - : null - }, - hasInvalidFeedback() { - // Used for computing aria-describedby - return ( - this.computedState === false && - (this.hasNormalizedSlot('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.hasNormalizedSlot('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, this.validFeedbackId] - .filter(Boolean) - .join(' ') || null - ) } }, watch: { - describedByIds(add, remove) { - if (add !== remove) { - this.setInputDescribedBy(add, remove) + describedByIds(newValue, oldValue) { + if (newValue !== oldValue) { + this.setInputDescribedBy(newValue, oldValue) } } }, mounted() { this.$nextTick(() => { - // Set the aria-describedby IDs on the input specified by label-for - // We do this in a nextTick to ensure the children have finished rendering + // Set the `aria-describedby` IDs on the input specified by `label-for` + // We do this in a `$nextTick()` to ensure the children have finished rendering this.setInputDescribedBy(this.describedByIds) }) }, methods: { legendClick(evt) { + // Don't do anything if labelFor is set + /* istanbul ignore next: clicking a label will focus the input, so no need to test */ if (this.labelFor) { - // Don't do anything if labelFor is set - /* istanbul ignore next: clicking a label will focus the input, so no need to test */ return } - const tagName = evt.target ? evt.target.tagName : '' - if (/^(input|select|textarea|label|button|a)$/i.test(tagName)) { - // If clicked an interactive element inside legend, - // we just let the default happen - /* istanbul ignore next */ + const { target } = evt + const tagName = target ? target.tagName : '' + // If clicked an interactive element inside legend, + // we just let the default happen + /* istanbul ignore next */ + if (LEGEND_INTERACTIVE_ELEMENTS.indexOf(tagName) !== -1) { return } - const inputs = selectAll(SELECTOR, this.$refs.content).filter(isVisible) + const inputs = selectAll(INPUT_SELECTOR, this.$refs.content).filter(isVisible) // If only a single input, focus it, emulating label behaviour if (inputs && inputs.length === 1) { attemptFocus(inputs[0]) } }, + // Sets the `aria-describedby` attribute on the input if label-for is set + // Optionally accepts a string of IDs to remove as the second parameter + // Preserves any `aria-describedby` value(s) user may have on input setInputDescribedBy(add, remove) { - // Sets the `aria-describedby` attribute on the input if label-for is set. - // Optionally accepts a string of IDs to remove as the second parameter. - // Preserves any aria-describedby value(s) user may have on input. if (this.labelFor && isBrowser) { // We need to escape `labelFor` since it can be user-provided const input = select(`#${cssEscape(this.labelFor)}`, this.$refs.content) @@ -409,12 +250,132 @@ export const BFormGroup = { } }, render(h) { - const isFieldset = !this.labelFor - const isHorizontal = this.isHorizontal - // Generate the label - const label = renderLabel(h, this) - // Generate the content - const content = h( + const { + labelFor, + tooltip, + feedbackAriaLive, + computedState: state, + isHorizontal, + normalizeSlot + } = this + const isFieldset = !labelFor + + let $label = h() + const labelContent = normalizeSlot(SLOT_NAME_LABEL) || this.label + const labelId = labelContent ? this.safeId('_BV_label_') : null + if (labelContent || isHorizontal) { + const { labelSize, labelColProps } = this + const isLegend = isFieldset + const labelTag = isLegend ? 'legend' : 'label' + if (this.labelSrOnly) { + if (labelContent) { + $label = h( + labelTag, + { + class: 'sr-only', + attrs: { id: labelId, for: labelFor || null } + }, + [labelContent] + ) + } + $label = h(isHorizontal ? BCol : 'div', { props: isHorizontal ? labelColProps : {} }, [ + $label + ]) + } else { + $label = h( + isHorizontal ? BCol : labelTag, + { + on: isLegend ? { click: this.legendClick } : {}, + props: isHorizontal ? { tag: labelTag, ...labelColProps } : {}, + attrs: { + id: labelId, + for: labelFor || null, + // We add a `tabindex` to legend so that screen readers + // will properly read the `aria-labelledby` in IE + tabindex: isLegend ? '-1' : null + }, + class: [ + // Hide the focus ring on the legend + isLegend ? 'bv-no-focus-ring' : '', + // When horizontal or if a legend is rendered, add 'col-form-label' class + // for correct sizing as Bootstrap has inconsistent font styling for + // legend in non-horizontal 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' : '', + // If not horizontal and not a legend, we add 'd-block' class to label + // so that label-align works + !isHorizontal && !isLegend ? 'd-block' : '', + labelSize ? `col-form-label-${labelSize}` : '', + this.labelAlignClasses, + this.labelClass + ] + }, + [labelContent] + ) + } + } + + let $invalidFeedback = h() + const invalidFeedbackContent = normalizeSlot('invalid-feedback') || this.invalidFeedback + const invalidFeedbackId = invalidFeedbackContent ? this.safeId('_BV_feedback_invalid_') : null + if (invalidFeedbackContent) { + $invalidFeedback = h( + BFormInvalidFeedback, + { + props: { + id: invalidFeedbackId, + // If state is explicitly `false`, always show the feedback + state, + tooltip, + ariaLive: feedbackAriaLive, + role: feedbackAriaLive ? 'alert' : null + }, + attrs: { tabindex: invalidFeedbackContent ? '-1' : null } + }, + [invalidFeedbackContent] + ) + } + + let $validFeedback = h() + const validFeedbackContent = normalizeSlot('valid-feedback') || this.validFeedback + const validFeedbackId = validFeedbackContent ? this.safeId('_BV_feedback_valid_') : null + if (validFeedbackContent) { + $validFeedback = h( + BFormValidFeedback, + { + props: { + id: validFeedbackId, + // If state is explicitly `true`, always show the feedback + state, + tooltip, + ariaLive: feedbackAriaLive, + role: feedbackAriaLive ? 'alert' : null + }, + attrs: { tabindex: validFeedbackContent ? '-1' : null } + }, + [validFeedbackContent] + ) + } + + let $description = h() + const descriptionContent = normalizeSlot(SLOT_NAME_DESCRIPTION) || this.description + const descriptionId = descriptionContent ? this.safeId('_BV_description_') : null + if (descriptionContent) { + $description = h( + BFormText, + { + attrs: { + id: descriptionId, + tabindex: descriptionContent ? '-1' : null + } + }, + [descriptionContent] + ) + } + + const $content = h( isHorizontal ? BCol : 'div', { ref: 'content', @@ -425,38 +386,44 @@ export const BFormGroup = { role: isFieldset ? 'group' : null } }, - [ - this.normalizeSlot() || h(), - renderInvalidFeedback(h, this), - renderValidFeedback(h, this), - renderHelpText(h, this) - ] + [normalizeSlot() || h(), $invalidFeedback, $validFeedback, $description] ) - // Create the form-group - const data = { - staticClass: 'form-group', - class: [this.validated ? 'was-validated' : null, this.stateClass], - attrs: { - id: this.safeId(), - disabled: isFieldset ? this.disabled : null, - role: isFieldset ? null : 'group', - 'aria-invalid': this.computedState === false ? 'true' : null, - // Only apply aria-labelledby if we are a horizontal fieldset - // as the legend is no longer a direct child of fieldset - 'aria-labelledby': isFieldset && isHorizontal ? this.labelId : null, - // Only apply aria-describedby IDs if we are a fieldset - // as the input will have the IDs when not a fieldset - 'aria-describedby': isFieldset ? this.describedByIds : null - } - } + + // Update the `aria-describedby` IDs + // 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 + this.describedByIds = [ + descriptionId, + state === false ? invalidFeedbackId : null, + state === true ? validFeedbackId : null + ] + .filter(Boolean) + .join(' ') + // 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 and using a fieldset return h( isFieldset ? 'fieldset' : isHorizontal ? BFormRow : 'div', - data, - isHorizontal && isFieldset ? [h(BFormRow, [label, content])] : [label, content] + { + staticClass: 'form-group', + class: [this.validated ? 'was-validated' : null, this.stateClass], + attrs: { + id: this.safeId(), + disabled: isFieldset ? this.disabled : null, + role: isFieldset ? null : 'group', + 'aria-invalid': state === false ? 'true' : null, + // Only apply aria-labelledby if we are a horizontal fieldset + // as the legend is no longer a direct child of fieldset + 'aria-labelledby': isFieldset && isHorizontal ? labelId : null, + // Only apply `aria-describedby` IDs if we are a fieldset + // as the input will have the IDs when not a fieldset + 'aria-describedby': isFieldset ? this.describedByIds : null + } + }, + isHorizontal && isFieldset ? [h(BFormRow, [$label, $content])] : [$label, $content] ) } }