quasar
Version: 
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
657 lines (549 loc) • 17.7 kB
JavaScript
import { h, ref, computed, onBeforeUnmount, getCurrentInstance } from 'vue'
import TouchPan from '../../directives/TouchPan.js'
import useDark, { useDarkProps } from '../../composables/private/use-dark.js'
import { useFormProps, useFormInject } from '../../composables/private/use-form.js'
import { between } from '../../utils/format.js'
import { position } from '../../utils/event.js'
import { isNumber, isObject } from '../../utils/is.js'
import { hDir } from '../../utils/private/render.js'
const markerPrefixClass = 'q-slider__marker-labels'
const defaultMarkerConvertFn = v => ({ value: v })
const defaultMarkerLabelRenderFn = ({ marker }) => h('div', {
  key: marker.value,
  style: marker.style,
  class: marker.classes
}, marker.label)
// PGDOWN, LEFT, DOWN, PGUP, RIGHT, UP
export const keyCodes = [ 34, 37, 40, 33, 39, 38 ]
export const useSliderProps = {
  ...useDarkProps,
  ...useFormProps,
  min: {
    type: Number,
    default: 0
  },
  max: {
    type: Number,
    default: 100
  },
  innerMin: Number,
  innerMax: Number,
  step: {
    type: Number,
    default: 1,
    validator: v => v >= 0
  },
  snap: Boolean,
  vertical: Boolean,
  reverse: Boolean,
  hideSelection: Boolean,
  color: String,
  markerLabelsClass: String,
  label: Boolean,
  labelColor: String,
  labelTextColor: String,
  labelAlways: Boolean,
  switchLabelSide: Boolean,
  markers: [ Boolean, Number ],
  markerLabels: [ Boolean, Array, Object, Function ],
  switchMarkerLabelsSide: Boolean,
  trackImg: String,
  trackColor: String,
  innerTrackImg: String,
  innerTrackColor: String,
  selectionColor: String,
  selectionImg: String,
  thumbSize: {
    type: String,
    default: '20px'
  },
  trackSize: {
    type: String,
    default: '4px'
  },
  disable: Boolean,
  readonly: Boolean,
  dense: Boolean,
  tabindex: [ String, Number ],
  thumbColor: String,
  thumbPath: {
    type: String,
    default: 'M 4, 10 a 6,6 0 1,0 12,0 a 6,6 0 1,0 -12,0'
  }
}
export const useSliderEmits = [ 'pan', 'update:modelValue', 'change' ]
export default function ({ updateValue, updatePosition, getDragging, formAttrs }) {
  const { props, emit, slots, proxy: { $q } } = getCurrentInstance()
  const isDark = useDark(props, $q)
  const injectFormInput = useFormInject(formAttrs)
  const active = ref(false)
  const preventFocus = ref(false)
  const focus = ref(false)
  const dragging = ref(false)
  const axis = computed(() => (props.vertical === true ? '--v' : '--h'))
  const labelSide = computed(() => '-' + (props.switchLabelSide === true ? 'switched' : 'standard'))
  const isReversed = computed(() => (
    props.vertical === true
      ? props.reverse === true
      : props.reverse !== ($q.lang.rtl === true)
  ))
  const innerMin = computed(() => (
    isNaN(props.innerMin) === true || props.innerMin < props.min
      ? props.min
      : props.innerMin
  ))
  const innerMax = computed(() => (
    isNaN(props.innerMax) === true || props.innerMax > props.max
      ? props.max
      : props.innerMax
  ))
  const editable = computed(() => (
    props.disable !== true && props.readonly !== true
    && innerMin.value < innerMax.value
  ))
  const decimals = computed(() => (String(props.step).trim().split('.')[ 1 ] || '').length)
  const step = computed(() => (props.step === 0 ? 1 : props.step))
  const tabindex = computed(() => (editable.value === true ? props.tabindex || 0 : -1))
  const trackLen = computed(() => props.max - props.min)
  const innerBarLen = computed(() => innerMax.value - innerMin.value)
  const innerMinRatio = computed(() => convertModelToRatio(innerMin.value))
  const innerMaxRatio = computed(() => convertModelToRatio(innerMax.value))
  const positionProp = computed(() => (
    props.vertical === true
      ? (isReversed.value === true ? 'bottom' : 'top')
      : (isReversed.value === true ? 'right' : 'left')
  ))
  const sizeProp = computed(() => (props.vertical === true ? 'height' : 'width'))
  const thicknessProp = computed(() => (props.vertical === true ? 'width' : 'height'))
  const orientation = computed(() => (props.vertical === true ? 'vertical' : 'horizontal'))
  const attributes = computed(() => {
    const acc = {
      role: 'slider',
      'aria-valuemin': innerMin.value,
      'aria-valuemax': innerMax.value,
      'aria-orientation': orientation.value,
      'data-step': props.step
    }
    if (props.disable === true) {
      acc[ 'aria-disabled' ] = 'true'
    }
    else if (props.readonly === true) {
      acc[ 'aria-readonly' ] = 'true'
    }
    return acc
  })
  const classes = computed(() =>
    `q-slider q-slider${ axis.value } q-slider--${ active.value === true ? '' : 'in' }active inline no-wrap `
    + (props.vertical === true ? 'row' : 'column')
    + (props.disable === true ? ' disabled' : ' q-slider--enabled' + (editable.value === true ? ' q-slider--editable' : ''))
    + (focus.value === 'both' ? ' q-slider--focus' : '')
    + (props.label || props.labelAlways === true ? ' q-slider--label' : '')
    + (props.labelAlways === true ? ' q-slider--label-always' : '')
    + (isDark.value === true ? ' q-slider--dark' : '')
    + (props.dense === true ? ' q-slider--dense q-slider--dense' + axis.value : '')
  )
  function getPositionClass (name) {
    const cls = 'q-slider__' + name
    return `${ cls } ${ cls }${ axis.value } ${ cls }${ axis.value }${ labelSide.value }`
  }
  function getAxisClass (name) {
    const cls = 'q-slider__' + name
    return `${ cls } ${ cls }${ axis.value }`
  }
  const selectionBarClass = computed(() => {
    const color = props.selectionColor || props.color
    return 'q-slider__selection absolute'
      + (color !== void 0 ? ` text-${ color }` : '')
  })
  const markerClass = computed(() => getAxisClass('markers') + ' absolute overflow-hidden')
  const trackContainerClass = computed(() => getAxisClass('track-container'))
  const pinClass = computed(() => getPositionClass('pin'))
  const labelClass = computed(() => getPositionClass('label'))
  const textContainerClass = computed(() => getPositionClass('text-container'))
  const markerLabelsContainerClass = computed(() =>
    getPositionClass('marker-labels-container')
    + (props.markerLabelsClass !== void 0 ? ` ${ props.markerLabelsClass }` : '')
  )
  const trackClass = computed(() =>
    'q-slider__track relative-position no-outline'
    + (props.trackColor !== void 0 ? ` bg-${ props.trackColor }` : '')
  )
  const trackStyle = computed(() => {
    const acc = { [ thicknessProp.value ]: props.trackSize }
    if (props.trackImg !== void 0) {
      acc.backgroundImage = `url(${ props.trackImg }) !important`
    }
    return acc
  })
  const innerBarClass = computed(() =>
    'q-slider__inner absolute'
    + (props.innerTrackColor !== void 0 ? ` bg-${ props.innerTrackColor }` : '')
  )
  const innerBarStyle = computed(() => {
    const acc = {
      [ positionProp.value ]: `${ 100 * innerMinRatio.value }%`,
      [ sizeProp.value ]: `${ 100 * (innerMaxRatio.value - innerMinRatio.value) }%`
    }
    if (props.innerTrackImg !== void 0) {
      acc.backgroundImage = `url(${ props.innerTrackImg }) !important`
    }
    return acc
  })
  function convertRatioToModel (ratio) {
    const { min, max, step } = props
    let model = min + ratio * (max - min)
    if (step > 0) {
      const modulo = (model - min) % step
      model += (Math.abs(modulo) >= step / 2 ? (modulo < 0 ? -1 : 1) * step : 0) - modulo
    }
    if (decimals.value > 0) {
      model = parseFloat(model.toFixed(decimals.value))
    }
    return between(model, innerMin.value, innerMax.value)
  }
  function convertModelToRatio (model) {
    return trackLen.value === 0
      ? 0
      : (model - props.min) / trackLen.value
  }
  function getDraggingRatio (evt, dragging) {
    const
      pos = position(evt),
      val = props.vertical === true
        ? between((pos.top - dragging.top) / dragging.height, 0, 1)
        : between((pos.left - dragging.left) / dragging.width, 0, 1)
    return between(
      isReversed.value === true ? 1.0 - val : val,
      innerMinRatio.value,
      innerMaxRatio.value
    )
  }
  const markerStep = computed(() => (
    isNumber(props.markers) === true ? props.markers : step.value)
  )
  const markerTicks = computed(() => {
    const acc = []
    const step = markerStep.value
    const max = props.max
    let value = props.min
    do {
      acc.push(value)
      value += step
    } while (value < max)
    acc.push(max)
    return acc
  })
  const markerLabelClass = computed(() => {
    const prefix = ` ${ markerPrefixClass }${ axis.value }-`
    return markerPrefixClass
      + `${ prefix }${ props.switchMarkerLabelsSide === true ? 'switched' : 'standard' }`
      + `${ prefix }${ isReversed.value === true ? 'rtl' : 'ltr' }`
  })
  const markerLabelsList = computed(() => {
    if (props.markerLabels === false) { return null }
    return getMarkerList(props.markerLabels).map((entry, index) => ({
      index,
      value: entry.value,
      label: entry.label || entry.value,
      classes: markerLabelClass.value
        + (entry.classes !== void 0 ? ' ' + entry.classes : ''),
      style: {
        ...getMarkerLabelStyle(entry.value),
        ...(entry.style || {})
      }
    }))
  })
  const markerScope = computed(() => ({
    markerList: markerLabelsList.value,
    markerMap: markerLabelsMap.value,
    classes: markerLabelClass.value, // TODO ts definition
    getStyle: getMarkerLabelStyle
  }))
  const markerStyle = computed(() => {
    if (innerBarLen.value !== 0) {
      const size = 100 * markerStep.value / innerBarLen.value
      return {
        ...innerBarStyle.value,
        backgroundSize: props.vertical === true
          ? `2px ${ size }%`
          : `${ size }% 2px`
      }
    }
    return null
  })
  function getMarkerList (def) {
    if (def === false) { return null }
    if (def === true) {
      return markerTicks.value.map(defaultMarkerConvertFn)
    }
    if (typeof def === 'function') {
      return markerTicks.value.map(value => {
        const item = def(value)
        return isObject(item) === true ? { ...item, value } : { value, label: item }
      })
    }
    const filterFn = ({ value }) => value >= props.min && value <= props.max
    if (Array.isArray(def) === true) {
      return def
        .map(item => (isObject(item) === true ? item : { value: item }))
        .filter(filterFn)
    }
    return Object.keys(def).map(key => {
      const item = def[ key ]
      const value = Number(key)
      return isObject(item) === true ? { ...item, value } : { value, label: item }
    }).filter(filterFn)
  }
  function getMarkerLabelStyle (val) {
    return { [ positionProp.value ]: `${ 100 * (val - props.min) / trackLen.value }%` }
  }
  const markerLabelsMap = computed(() => {
    if (props.markerLabels === false) { return null }
    const acc = {}
    markerLabelsList.value.forEach(entry => {
      acc[ entry.value ] = entry
    })
    return acc
  })
  function getMarkerLabelsContent () {
    if (slots[ 'marker-label-group' ] !== void 0) {
      return slots[ 'marker-label-group' ](markerScope.value)
    }
    const fn = slots[ 'marker-label' ] || defaultMarkerLabelRenderFn
    return markerLabelsList.value.map(marker => fn({
      marker,
      ...markerScope.value
    }))
  }
  const panDirective = computed(() => {
    // if editable.value === true
    return [ [
      TouchPan,
      onPan,
      void 0,
      {
        [ orientation.value ]: true,
        prevent: true,
        stop: true,
        mouse: true,
        mouseAllDir: true
      }
    ] ]
  })
  function onPan (event) {
    if (event.isFinal === true) {
      if (dragging.value !== void 0) {
        updatePosition(event.evt)
        // only if touch, because we also have mousedown/up:
        event.touch === true && updateValue(true)
        dragging.value = void 0
        emit('pan', 'end')
      }
      active.value = false
      focus.value = false
    }
    else if (event.isFirst === true) {
      dragging.value = getDragging(event.evt)
      updatePosition(event.evt)
      updateValue()
      active.value = true
      emit('pan', 'start')
    }
    else {
      updatePosition(event.evt)
      updateValue()
    }
  }
  function onBlur () {
    focus.value = false
  }
  function onActivate (evt) {
    updatePosition(evt, getDragging(evt))
    updateValue()
    preventFocus.value = true
    active.value = true
    document.addEventListener('mouseup', onDeactivate, true)
  }
  function onDeactivate () {
    preventFocus.value = false
    active.value = false
    updateValue(true)
    onBlur()
    document.removeEventListener('mouseup', onDeactivate, true)
  }
  function onMobileClick (evt) {
    updatePosition(evt, getDragging(evt))
    updateValue(true)
  }
  function onKeyup (evt) {
    if (keyCodes.includes(evt.keyCode)) {
      updateValue(true)
    }
  }
  function getTextContainerStyle (ratio) {
    if (props.vertical === true) { return null }
    const p = $q.lang.rtl !== props.reverse ? 1 - ratio : ratio
    return {
      transform: `translateX(calc(${ 2 * p - 1 } * ${ props.thumbSize } / 2 + ${ 50 - 100 * p }%))`
    }
  }
  function getThumbRenderFn (thumb) {
    const focusClass = computed(() => (
      preventFocus.value === false && (focus.value === thumb.focusValue || focus.value === 'both')
        ? ' q-slider--focus'
        : ''
    ))
    const classes = computed(() =>
      `q-slider__thumb q-slider__thumb${ axis.value } q-slider__thumb${ axis.value }-${ isReversed.value === true ? 'rtl' : 'ltr' } absolute non-selectable`
      + focusClass.value
      + (thumb.thumbColor.value !== void 0 ? ` text-${ thumb.thumbColor.value }` : '')
    )
    const style = computed(() => ({
      width: props.thumbSize,
      height: props.thumbSize,
      [ positionProp.value ]: `${ 100 * thumb.ratio.value }%`,
      zIndex: focus.value === thumb.focusValue ? 2 : void 0
    }))
    const pinColor = computed(() => (
      thumb.labelColor.value !== void 0
        ? ` text-${ thumb.labelColor.value }`
        : ''
    ))
    const textContainerStyle = computed(() => getTextContainerStyle(thumb.ratio.value))
    const textClass = computed(() => (
      'q-slider__text'
      + (thumb.labelTextColor.value !== void 0 ? ` text-${ thumb.labelTextColor.value }` : '')
    ))
    return () => {
      const thumbContent = [
        h('svg', {
          class: 'q-slider__thumb-shape absolute-full',
          viewBox: '0 0 20 20',
          'aria-hidden': 'true'
        }, [
          h('path', { d: props.thumbPath })
        ]),
        h('div', { class: 'q-slider__focus-ring fit' })
      ]
      if (props.label === true || props.labelAlways === true) {
        thumbContent.push(
          h('div', {
            class: pinClass.value + ' absolute fit no-pointer-events' + pinColor.value
          }, [
            h('div', {
              class: labelClass.value,
              style: { minWidth: props.thumbSize }
            }, [
              h('div', {
                class: textContainerClass.value,
                style: textContainerStyle.value
              }, [
                h('span', { class: textClass.value }, thumb.label.value)
              ])
            ])
          ])
        )
        if (props.name !== void 0 && props.disable !== true) {
          injectFormInput(thumbContent, 'push')
        }
      }
      return h('div', {
        class: classes.value,
        style: style.value,
        ...thumb.getNodeData()
      }, thumbContent)
    }
  }
  function getContent (selectionBarStyle, trackContainerTabindex, trackContainerEvents, injectThumb) {
    const trackContent = []
    props.innerTrackColor !== 'transparent' && trackContent.push(
      h('div', {
        key: 'inner',
        class: innerBarClass.value,
        style: innerBarStyle.value
      })
    )
    props.selectionColor !== 'transparent' && trackContent.push(
      h('div', {
        key: 'selection',
        class: selectionBarClass.value,
        style: selectionBarStyle.value
      })
    )
    props.markers !== false && trackContent.push(
      h('div', {
        key: 'marker',
        class: markerClass.value,
        style: markerStyle.value
      })
    )
    injectThumb(trackContent)
    const content = [
      hDir(
        'div',
        {
          key: 'trackC',
          class: trackContainerClass.value,
          tabindex: trackContainerTabindex.value,
          ...trackContainerEvents.value
        },
        [
          h('div', {
            class: trackClass.value,
            style: trackStyle.value
          }, trackContent)
        ],
        'slide',
        editable.value, () => panDirective.value
      )
    ]
    if (props.markerLabels !== false) {
      const action = props.switchMarkerLabelsSide === true
        ? 'unshift'
        : 'push'
      content[ action ](
        h('div', {
          key: 'markerL',
          class: markerLabelsContainerClass.value
        }, getMarkerLabelsContent())
      )
    }
    return content
  }
  onBeforeUnmount(() => {
    document.removeEventListener('mouseup', onDeactivate, true)
  })
  return {
    state: {
      active,
      focus,
      preventFocus,
      dragging,
      editable,
      classes,
      tabindex,
      attributes,
      step,
      decimals,
      trackLen,
      innerMin,
      innerMinRatio,
      innerMax,
      innerMaxRatio,
      positionProp,
      sizeProp,
      isReversed
    },
    methods: {
      onActivate,
      onMobileClick,
      onBlur,
      onKeyup,
      getContent,
      getThumbRenderFn,
      convertRatioToModel,
      convertModelToRatio,
      getDraggingRatio
    }
  }
}