UNPKG

bootstrap-vue

Version:

BootstrapVue, with over 40 plugins and more than 80 custom components, custom directives, and over 300 icons, provides one of the most comprehensive implementations of Bootstrap v4 components and grid system for Vue.js. With extensive and automated WAI-AR

454 lines (440 loc) 14.9 kB
// Utils import memoize from '../../utils/memoize' import { arrayIncludes } from '../../utils/array' import { getBreakpointsUpCached } from '../../utils/config' import { select, selectAll, isVisible, setAttr, removeAttr, getAttr } from '../../utils/dom' import { isBrowser } from '../../utils/env' import { isBoolean } from '../../utils/inspect' import { keys, create } from '../../utils/object' import { upperFirst } from '../../utils/string' // Mixins import formStateMixin from '../../mixins/form-state' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' // Sub components 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' // Component name const NAME = 'BFormGroup' // Selector for finding first input in the form-group const SELECTOR = 'input:not([disabled]),textarea:not([disabled]),select:not([disabled])' // Render helper functions (here rather than polluting the instance with more 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('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('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 BREAKPOINTS = getBreakpointsUpCached() // Generate the labelCol breakpoint props const bpLabelColProps = 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 = 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, 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 = parseInt(propVal, 10) || 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('label') || this.label ? this.safeId('_BV_label_') : null }, descriptionId() { return this.hasNormalizedSlot('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 (inputs && inputs.length === 1 && inputs[0].focus) { // if only a single input, focus it, emulating label behaviour try { inputs[0].focus() } catch {} } }, 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) { const input = select(`#${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('default') || 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] ) } }