UNPKG

bootstrap-vue

Version:

With more than 85 components, over 45 available plugins, several directives, and 1000+ icons, BootstrapVue provides one of the most comprehensive implementations of the Bootstrap v4 component and grid system available for Vue.js v2.6, complete with extens

463 lines (449 loc) 15.1 kB
import { NAME_FORM_GROUP } from '../../constants/components' import { SLOT_NAME_DESCRIPTION, SLOT_NAME_LABEL } from '../../constants/slot-names' import cssEscape from '../../utils/css-escape' import memoize from '../../utils/memoize' import { arrayIncludes } from '../../utils/array' import { getBreakpointsUpCached } from '../../utils/config' import { select, selectAll, isVisible, setAttr, removeAttr, getAttr, attemptFocus } from '../../utils/dom' import { isBrowser } from '../../utils/env' import { isBoolean } from '../../utils/inspect' import { toInteger } from '../../utils/number' import { keys, create } from '../../utils/object' import { upperFirst } from '../../utils/string' import formStateMixin from '../../mixins/form-state' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' import { BCol } from '../layout/col' import { BFormRow } from '../layout/form-row' import { BFormText } from '../form/form-text' import { BFormInvalidFeedback } from '../form/form-invalid-feedback' 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])' // --- 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 } 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 // Memoize this function to return cached values to // save time in computed functions const makePropName = memoize((breakpoint = '', prefix) => { return `${prefix}${upperFirst(breakpoint)}` }) // BFormGroup prop generator for lazy generation of props const generateProps = () => { const CODE_BREAKPOINTS = getBreakpointsUpCached() // Generate the labelCol breakpoint props const bpLabelColProps = CODE_BREAKPOINTS.reduce((props, breakpoint) => { // i.e. label-cols, label-cols-sm, label-cols-md, ... props[makePropName(breakpoint, 'labelCols')] = { type: [Number, String, Boolean], default: breakpoint ? false : null } return props }, create(null)) // Generate the labelAlign breakpoint props const bpLabelAlignProps = CODE_BREAKPOINTS.reduce((props, breakpoint) => { // label-align, label-align-sm, label-align-md, ... props[makePropName(breakpoint, 'labelAlign')] = { type: String // left, right, center // default: null } return props }, create(null)) return { label: { type: String // default: null }, labelFor: { type: String // default: null }, labelSize: { type: String // default: null }, labelSrOnly: { type: Boolean, default: false }, // label-cols prop and all label-cols-{bp} props ...bpLabelColProps, // label-align prop and all label-align-{bp} props ...bpLabelAlignProps, labelClass: { type: [String, Array, Object] // default: null }, description: { type: String // default: null }, invalidFeedback: { type: String // default: null }, validFeedback: { type: String // default: null }, tooltip: { // Enable tooltip style feedback type: Boolean, default: false }, feedbackAriaLive: { type: String, default: 'assertive' }, validated: { type: Boolean, default: false }, disabled: { type: Boolean, default: false } } } // We do not use Vue.extend here as that would evaluate the props // immediately, which we do not want to happen // @vue/component export const BFormGroup = { name: NAME_FORM_GROUP, mixins: [idMixin, formStateMixin, normalizeSlotMixin], get props() { // Allow props to be lazy evaled on first access and // 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()) }, computed: { labelColProps() { const props = {} getBreakpointsUpCached().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 (!isBoolean(propVal) && propVal !== 'auto') { // Convert to column size to number propVal = toInteger(propVal, 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 } }) return props }, labelAlignClasses() { const classes = [] getBreakpointsUpCached().forEach(breakpoint => { // Assemble the label column breakpoint align classes const propVal = this[makePropName(breakpoint, 'labelAlign')] || null if (propVal) { const className = breakpoint ? `text-${breakpoint}-${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.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) } } }, 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 this.setInputDescribedBy(this.describedByIds) }) }, methods: { legendClick(evt) { 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 */ return } const inputs = selectAll(SELECTOR, this.$refs.content).filter(isVisible) // If only a single input, focus it, emulating label behaviour if (inputs && inputs.length === 1) { attemptFocus(inputs[0]) } }, 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) if (input) { const adb = 'aria-describedby' let ids = (getAttr(input, adb) || '').split(/\s+/) add = (add || '').split(/\s+/) remove = (remove || '').split(/\s+/) // Update ID list, preserving any original IDs // and ensuring the ID's are unique ids = ids .filter(id => !arrayIncludes(remove, id)) .concat(add) .filter(Boolean) ids = keys(ids.reduce((memo, id) => ({ ...memo, [id]: true }), {})) .join(' ') .trim() if (ids) { setAttr(input, adb, ids) } else { // No IDs, so remove the attribute removeAttr(input, adb) } } } } }, render(h) { const isFieldset = !this.labelFor const isHorizontal = this.isHorizontal // Generate the label const label = renderLabel(h, this) // Generate the content const content = h( isHorizontal ? BCol : 'div', { ref: 'content', // Hide focus ring staticClass: 'bv-no-focus-ring', attrs: { tabindex: isFieldset ? '-1' : null, role: isFieldset ? 'group' : null } }, [ this.normalizeSlot() || h(), renderInvalidFeedback(h, this), renderValidFeedback(h, this), renderHelpText(h, this) ] ) // 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 } } // 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] ) } }