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
412 lines (393 loc) • 12.5 kB
JavaScript
import { extend } from '../../vue'
import { NAME_FORM_RATING, NAME_FORM_RATING_STAR } from '../../constants/components'
import { EVENT_NAME_CHANGE, EVENT_NAME_SELECTED } from '../../constants/events'
import {
PROP_TYPE_ARRAY_STRING,
PROP_TYPE_BOOLEAN,
PROP_TYPE_NUMBER,
PROP_TYPE_NUMBER_STRING,
PROP_TYPE_STRING
} from '../../constants/props'
import { CODE_LEFT, CODE_RIGHT, CODE_UP, CODE_DOWN } from '../../constants/key-codes'
import {
SLOT_NAME_ICON_CLEAR,
SLOT_NAME_ICON_EMPTY,
SLOT_NAME_ICON_FULL,
SLOT_NAME_ICON_HALF
} from '../../constants/slots'
import { arrayIncludes, concat } from '../../utils/array'
import { attemptBlur, attemptFocus } from '../../utils/dom'
import { stopEvent } from '../../utils/events'
import { identity } from '../../utils/identity'
import { isNull } from '../../utils/inspect'
import { isLocaleRTL } from '../../utils/locale'
import { mathMax, mathMin } from '../../utils/math'
import { makeModelMixin } from '../../utils/model'
import { toInteger, toFloat } from '../../utils/number'
import { omit, sortKeys } from '../../utils/object'
import { makeProp, makePropsConfigurable } from '../../utils/props'
import { toString } from '../../utils/string'
import { formSizeMixin, props as formSizeProps } from '../../mixins/form-size'
import { idMixin, props as idProps } from '../../mixins/id'
import { normalizeSlotMixin } from '../../mixins/normalize-slot'
import { props as formControlProps } from '../../mixins/form-control'
import { BIcon } from '../../icons/icon'
import { BIconStar, BIconStarHalf, BIconStarFill, BIconX } from '../../icons/icons'
// --- Constants ---
const {
mixin: modelMixin,
props: modelProps,
prop: MODEL_PROP_NAME,
event: MODEL_EVENT_NAME
} = makeModelMixin('value', {
type: PROP_TYPE_NUMBER_STRING,
event: EVENT_NAME_CHANGE
})
const MIN_STARS = 3
const DEFAULT_STARS = 5
// --- Helper methods ---
const computeStars = stars => mathMax(MIN_STARS, toInteger(stars, DEFAULT_STARS))
const clampValue = (value, min, max) => mathMax(mathMin(value, max), min)
// --- Helper components ---
// @vue/component
const BVFormRatingStar = extend({
name: NAME_FORM_RATING_STAR,
mixins: [normalizeSlotMixin],
props: {
disabled: makeProp(PROP_TYPE_BOOLEAN, false),
// If parent is focused
focused: makeProp(PROP_TYPE_BOOLEAN, false),
hasClear: makeProp(PROP_TYPE_BOOLEAN, false),
rating: makeProp(PROP_TYPE_NUMBER, 0),
readonly: makeProp(PROP_TYPE_BOOLEAN, false),
star: makeProp(PROP_TYPE_NUMBER, 0),
variant: makeProp(PROP_TYPE_STRING)
},
methods: {
onClick(event) {
if (!this.disabled && !this.readonly) {
stopEvent(event, { propagation: false })
this.$emit(EVENT_NAME_SELECTED, this.star)
}
}
},
render(h) {
const { rating, star, focused, hasClear, variant, disabled, readonly } = this
const minStar = hasClear ? 0 : 1
const type = rating >= star ? 'full' : rating >= star - 0.5 ? 'half' : 'empty'
const slotScope = { variant, disabled, readonly }
return h(
'span',
{
staticClass: 'b-rating-star',
class: {
// When not hovered, we use this class to focus the current (or first) star
focused: (focused && rating === star) || (!toInteger(rating) && star === minStar),
// We add type classes to we can handle RTL styling
'b-rating-star-empty': type === 'empty',
'b-rating-star-half': type === 'half',
'b-rating-star-full': type === 'full'
},
attrs: { tabindex: !disabled && !readonly ? '-1' : null },
on: { click: this.onClick }
},
[h('span', { staticClass: 'b-rating-icon' }, [this.normalizeSlot(type, slotScope)])]
)
}
})
// --- Props ---
export const props = makePropsConfigurable(
sortKeys({
...idProps,
...modelProps,
...omit(formControlProps, ['required', 'autofocus']),
...formSizeProps,
// CSS color string (overrides variant)
color: makeProp(PROP_TYPE_STRING),
iconClear: makeProp(PROP_TYPE_STRING, 'x'),
iconEmpty: makeProp(PROP_TYPE_STRING, 'star'),
iconFull: makeProp(PROP_TYPE_STRING, 'star-fill'),
iconHalf: makeProp(PROP_TYPE_STRING, 'star-half'),
inline: makeProp(PROP_TYPE_BOOLEAN, false),
// Locale for the formatted value (if shown)
// Defaults to the browser locale. Falls back to `en`
locale: makeProp(PROP_TYPE_ARRAY_STRING),
noBorder: makeProp(PROP_TYPE_BOOLEAN, false),
precision: makeProp(PROP_TYPE_NUMBER_STRING),
readonly: makeProp(PROP_TYPE_BOOLEAN, false),
showClear: makeProp(PROP_TYPE_BOOLEAN, false),
showValue: makeProp(PROP_TYPE_BOOLEAN, false),
showValueMax: makeProp(PROP_TYPE_BOOLEAN, false),
stars: makeProp(PROP_TYPE_NUMBER_STRING, DEFAULT_STARS, value => {
return toInteger(value) >= MIN_STARS
}),
variant: makeProp(PROP_TYPE_STRING)
}),
NAME_FORM_RATING
)
// --- Main component ---
// @vue/component
export const BFormRating = /*#__PURE__*/ extend({
name: NAME_FORM_RATING,
components: { BIconStar, BIconStarHalf, BIconStarFill, BIconX },
mixins: [idMixin, modelMixin, formSizeMixin],
props,
data() {
const value = toFloat(this[MODEL_PROP_NAME], null)
const stars = computeStars(this.stars)
return {
localValue: isNull(value) ? null : clampValue(value, 0, stars),
hasFocus: false
}
},
computed: {
computedStars() {
return computeStars(this.stars)
},
computedRating() {
const value = toFloat(this.localValue, 0)
const precision = toInteger(this.precision, 3)
// We clamp the value between `0` and stars
return clampValue(toFloat(value.toFixed(precision)), 0, this.computedStars)
},
computedLocale() {
const locales = concat(this.locale).filter(identity)
const nf = new Intl.NumberFormat(locales)
return nf.resolvedOptions().locale
},
isInteractive() {
return !this.disabled && !this.readonly
},
isRTL() {
return isLocaleRTL(this.computedLocale)
},
formattedRating() {
const precision = toInteger(this.precision)
const showValueMax = this.showValueMax
const locale = this.computedLocale
const formatOptions = {
notation: 'standard',
minimumFractionDigits: isNaN(precision) ? 0 : precision,
maximumFractionDigits: isNaN(precision) ? 3 : precision
}
const stars = this.computedStars.toLocaleString(locale)
let value = this.localValue
value = isNull(value)
? showValueMax
? '-'
: ''
: value.toLocaleString(locale, formatOptions)
return showValueMax ? `${value}/${stars}` : value
}
},
watch: {
[MODEL_PROP_NAME](newValue, oldValue) {
if (newValue !== oldValue) {
const value = toFloat(newValue, null)
this.localValue = isNull(value) ? null : clampValue(value, 0, this.computedStars)
}
},
localValue(newValue, oldValue) {
if (newValue !== oldValue && newValue !== (this.value || 0)) {
this.$emit(MODEL_EVENT_NAME, newValue || null)
}
},
disabled(newValue) {
if (newValue) {
this.hasFocus = false
this.blur()
}
}
},
methods: {
// --- Public methods ---
focus() {
if (!this.disabled) {
attemptFocus(this.$el)
}
},
blur() {
if (!this.disabled) {
attemptBlur(this.$el)
}
},
// --- Private methods ---
onKeydown(event) {
const { keyCode } = event
if (
this.isInteractive &&
arrayIncludes([CODE_LEFT, CODE_DOWN, CODE_RIGHT, CODE_UP], keyCode)
) {
stopEvent(event, { propagation: false })
const value = toInteger(this.localValue, 0)
const min = this.showClear ? 0 : 1
const stars = this.computedStars
// In RTL mode, LEFT/RIGHT are swapped
const amountRtl = this.isRTL ? -1 : 1
if (keyCode === CODE_LEFT) {
this.localValue = clampValue(value - amountRtl, min, stars) || null
} else if (keyCode === CODE_RIGHT) {
this.localValue = clampValue(value + amountRtl, min, stars)
} else if (keyCode === CODE_DOWN) {
this.localValue = clampValue(value - 1, min, stars) || null
} else if (keyCode === CODE_UP) {
this.localValue = clampValue(value + 1, min, stars)
}
}
},
onSelected(value) {
if (this.isInteractive) {
this.localValue = value
}
},
onFocus(event) {
this.hasFocus = !this.isInteractive ? false : event.type === 'focus'
},
// --- Render methods ---
renderIcon(icon) {
return this.$createElement(BIcon, {
props: {
icon,
variant: this.disabled || this.color ? null : this.variant || null
}
})
},
iconEmptyFn() {
return this.renderIcon(this.iconEmpty)
},
iconHalfFn() {
return this.renderIcon(this.iconHalf)
},
iconFullFn() {
return this.renderIcon(this.iconFull)
},
iconClearFn() {
return this.$createElement(BIcon, { props: { icon: this.iconClear } })
}
},
render(h) {
const {
disabled,
readonly,
name,
form,
inline,
variant,
color,
noBorder,
hasFocus,
computedRating,
computedStars,
formattedRating,
showClear,
isRTL,
isInteractive,
$scopedSlots
} = this
const $content = []
if (showClear && !disabled && !readonly) {
const $icon = h('span', { staticClass: 'b-rating-icon' }, [
($scopedSlots[SLOT_NAME_ICON_CLEAR] || this.iconClearFn)()
])
$content.push(
h(
'span',
{
staticClass: 'b-rating-star b-rating-star-clear flex-grow-1',
class: { focused: hasFocus && computedRating === 0 },
attrs: { tabindex: isInteractive ? '-1' : null },
on: { click: () => this.onSelected(null) },
key: 'clear'
},
[$icon]
)
)
}
for (let index = 0; index < computedStars; index++) {
const value = index + 1
$content.push(
h(BVFormRatingStar, {
staticClass: 'flex-grow-1',
style: color && !disabled ? { color } : {},
props: {
rating: computedRating,
star: value,
variant: disabled ? null : variant || null,
disabled,
readonly,
focused: hasFocus,
hasClear: showClear
},
on: { selected: this.onSelected },
scopedSlots: {
empty: $scopedSlots[SLOT_NAME_ICON_EMPTY] || this.iconEmptyFn,
half: $scopedSlots[SLOT_NAME_ICON_HALF] || this.iconHalfFn,
full: $scopedSlots[SLOT_NAME_ICON_FULL] || this.iconFullFn
},
key: index
})
)
}
if (name) {
$content.push(
h('input', {
attrs: {
type: 'hidden',
value: isNull(this.localValue) ? '' : computedRating,
name,
form: form || null
},
key: 'hidden'
})
)
}
if (this.showValue) {
$content.push(
h(
'b',
{
staticClass: 'b-rating-value flex-grow-1',
attrs: { 'aria-hidden': 'true' },
key: 'value'
},
toString(formattedRating)
)
)
}
return h(
'output',
{
staticClass: 'b-rating form-control align-items-center',
class: [
{
'd-inline-flex': inline,
'd-flex': !inline,
'border-0': noBorder,
disabled,
readonly: !disabled && readonly
},
this.sizeFormClass
],
attrs: {
id: this.safeId(),
dir: isRTL ? 'rtl' : 'ltr',
tabindex: disabled ? null : '0',
disabled,
role: 'slider',
'aria-disabled': disabled ? 'true' : null,
'aria-readonly': !disabled && readonly ? 'true' : null,
'aria-live': 'off',
'aria-valuemin': showClear ? '0' : '1',
'aria-valuemax': toString(computedStars),
'aria-valuenow': computedRating ? toString(computedRating) : null
},
on: {
keydown: this.onKeydown,
focus: this.onFocus,
blur: this.onFocus
}
},
$content
)
}
})