buefy
Version:
Lightweight UI components for Vue.js (v3) based on Bulma
528 lines (487 loc) • 17.1 kB
text/typescript
import { Comment, Fragment, Static, Text } from 'vue'
import type { App, ComponentPublicInstance, VNode } from 'vue'
// augments Vue App to deal with plugins
declare module '@vue/runtime-core' {
interface App {
// introduced by vue-i18n
__VUE_I18N_SYMBOL__?: symbol
}
}
// Type utility that extracts props type from a component constructor.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ExtractComponentProps<T> = T extends { new (...args: any[]): infer U }
// I thought `U extends ComponentPublicInstance<infer P>` would work,
// but it didn't
? U extends { $props: infer P }
// makes fields of `$props` mutable and optional
? { -readonly [Key in keyof P]?: P[Key] }
: Record<string, never>
: Record<string, never>
// Type utility that extracts data type from a component constructor.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ExtractComponentData<T> = T extends { new (...args: any[]): infer U }
// I thought `U extends ComponentPublicInstance<infer D>` would work,
// but it didn't
? U extends { $data: infer D }
// makes fields of `$data` optional
? { [Key in keyof D]?: D[Key] }
: Record<string, never>
: Record<string, never>
/*
* +/- function to native math sign
*
* @internal
*/
function signPoly(value: number): number {
if (value < 0) return -1
return value > 0 ? 1 : 0
}
export const sign = Math.sign || signPoly
/*
* Checks if the flag is set
* @param val
* @param flag
* @returns {boolean}
*
* @internal
*/
export function hasFlag(val: number, flag: number): boolean {
return (val & flag) === flag
}
/*
* Native modulo bug with negative numbers
* @param n
* @param mod
* @returns {number}
*
* @internal
*/
export function mod(n: number, mod: number): number {
return ((n % mod) + mod) % mod
}
/*
* Asserts a value is beetween min and max
* @param val
* @param min
* @param max
* @returns {number}
*
* @internal
*/
export function bound(val: number, min: number, max: number): number {
return Math.max(min, Math.min(max, val))
}
/*
* Get value of an object property/path even if it's nested
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getValueByPath(obj: any, path: string): any {
return path.split('.').reduce((o, i) => o ? o[i] : null, obj)
}
/*
* Extension of indexOf method by equality function if specified
*
* @internal
*/
export function indexOf<T>(
array: T[] | null | undefined,
obj: T,
fn?: (a: T, b: T) => boolean
): number {
if (!array) return -1
if (!fn || typeof fn !== 'function') return array.indexOf(obj)
for (let i = 0; i < array.length; i++) {
if (fn(array[i], obj)) {
return i
}
}
return -1
}
// Deep partial type.
//
// There are some edge cases where this type is not sufficient in general,
// but it works for this library.
// https://stackoverflow.com/questions/61132262/typescript-deep-partial
type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> }
/*
* Merge function to replace Object.assign with deep merging possibility
*
* @internal
*/
const isObject = (item: unknown) => typeof item === 'object' && !Array.isArray(item)
const mergeFn = <T>(
target: { [K in keyof T]: T[K] },
source: DeepPartial<T>,
deep = false
): { [K in keyof T]: T[K] } => {
if (deep || !Object.assign) {
const isDeep = (prop: keyof T) =>
isObject(source[prop]) &&
target !== null &&
Object.prototype.hasOwnProperty.call(target, prop) &&
isObject(target[prop])
const replaced = (Object.getOwnPropertyNames(source) as (keyof T)[])
.map((prop) => ({
[prop]: isDeep(prop)
? mergeFn<T[keyof T]>(target[prop], source[prop] || {}, deep)
: source[prop]
}))
.reduce(
(a, b) => ({ ...a, ...b }),
// eslint-disable-next-line no-use-before-define
{}
)
return {
...target,
...replaced
}
} else {
return Object.assign(target, source)
}
}
export const merge = mergeFn
/*
* Mobile detection
* https://www.abeautifulsite.net/detecting-mobile-devices-with-javascript
*
* @internal
*/
export const isMobile = {
Android: function () {
return (
typeof window !== 'undefined' &&
window.navigator.userAgent.match(/Android/i)
)
},
BlackBerry: function () {
return (
typeof window !== 'undefined' &&
window.navigator.userAgent.match(/BlackBerry/i)
)
},
iOS: function () {
return (
typeof window !== 'undefined' &&
(window.navigator.userAgent.match(/iPhone|iPad|iPod/i) ||
(window.navigator.platform === 'MacIntel' &&
window.navigator.maxTouchPoints > 1))
)
},
Opera: function () {
return (
typeof window !== 'undefined' &&
window.navigator.userAgent.match(/Opera Mini/i)
)
},
Windows: function () {
return (
typeof window !== 'undefined' &&
window.navigator.userAgent.match(/IEMobile/i)
)
},
any: function () {
return (
isMobile.Android() ||
isMobile.BlackBerry() ||
isMobile.iOS() ||
isMobile.Opera() ||
isMobile.Windows()
)
}
} as const
export function removeElement(el: Element) {
if (typeof el.remove !== 'undefined') {
el.remove()
} else if (typeof el.parentNode !== 'undefined' && el.parentNode !== null) {
el.parentNode.removeChild(el)
}
}
export function createAbsoluteElement(el: Element): HTMLElement {
const root = document.createElement('div')
root.style.position = 'absolute'
root.style.left = '0px'
root.style.top = '0px'
root.style.width = '100%'
const wrapper = document.createElement('div')
root.appendChild(wrapper)
wrapper.appendChild(el)
document.body.appendChild(root)
return root
}
export function isVueComponent(c: unknown): c is ComponentPublicInstance {
return c != null &&
(c as ComponentPublicInstance).$ != null &&
(c as ComponentPublicInstance).$.vnode != null
}
/*
* Escape regex characters
* http://stackoverflow.com/a/6969486
*
* @internal
*/
export function escapeRegExpChars(value: string | null | undefined): string | null | undefined {
if (!value) return value
// eslint-disable-next-line
return value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&')
}
/*
* Remove accents/diacritics in a string in JavaScript
* https://stackoverflow.com/a/37511463
*
* @internal
*/
export function removeDiacriticsFromString(value: string): string {
if (!value) return value
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
}
export interface MultiColumnSortPriority<T> {
field?: string
order: 'asc' | 'desc'
customSort?: (a: Record<string, T>, b: Record<string, T>, isAscending: boolean) => number
}
export function multiColumnSort<T>(
inputArray: Record<string, T>[],
sortingPriority: MultiColumnSortPriority<T>[]
): Record<string, T>[] {
// NOTE: this function is intended to be used by BTable
// clone it to prevent the any watchers from triggering every sorting iteration
const array: Record<string, T>[] = JSON.parse(JSON.stringify(inputArray))
const fieldSorter = (fields: MultiColumnSortPriority<T>[]) => (
a: Record<string, T>,
b: Record<string, T>
) => fields.map((o) => {
const { field, order, customSort } = o
if (typeof customSort === 'function') {
return customSort(a, b, order !== 'desc')
} else {
const aValue = getValueByPath(a, field!)
const bValue = getValueByPath(b, field!)
const ord = aValue > bValue ? 1 : aValue < bValue ? -1 : 0
return order === 'desc' ? -ord : ord
}
}).reduce((p, n) => p || n, 0)
return array.sort(fieldSorter(sortingPriority))
}
export function createNewEvent(eventName: string): Event {
let event
if (typeof Event === 'function') {
event = new Event(eventName)
} else {
event = document.createEvent('Event')
event.initEvent(eventName, true, true)
}
return event
}
export function toCssWidth(width: number | string | undefined): string | null {
return width === undefined ? null : (isNaN(+width) ? `${width}` : width + 'px')
}
/*
* Return month names according to a specified locale
* @param {String} locale A bcp47 localerouter. undefined will use the user browser locale
* @param {String} format long (ex. March), short (ex. Mar) or narrow (M)
* @return {Array<String>} An array of month names
*
* @internal
*/
export function getMonthNames(locale?: string | string[], format: Intl.DateTimeFormatOptions['month'] = 'long'): string[] {
const dates = []
for (let i = 0; i < 12; i++) {
dates.push(new Date(2000, i, 15))
}
const dtf = new Intl.DateTimeFormat(locale, {
month: format
})
return dates.map((d) => dtf.format(d))
}
/*
* Return weekday names according to a specified locale
* @param {String} locale A bcp47 localerouter. undefined will use the user browser locale
* @param {String} format long (ex. Thursday), short (ex. Thu) or narrow (T)
* @return {Array<String>} An array of weekday names
*
* @internal
*/
export function getWeekdayNames(locale?: string | string[], format: Intl.DateTimeFormatOptions['weekday'] = 'narrow'): string[] {
const dates = []
for (let i = 0; i < 7; i++) {
const dt = new Date(2000, 0, i + 1)
dates[dt.getDay()] = dt
}
const dtf = new Intl.DateTimeFormat(locale, { weekday: format })
return dates.map((d) => dtf.format(d))
}
/*
* Accept a regex with group names and return an object
* ex. matchWithGroups(/((?!=<year>)\d+)\/((?!=<month>)\d+)\/((?!=<day>)\d+)/, '2000/12/25')
* will return { year: 2000, month: 12, day: 25 }
* @param {String} includes injections of (?!={groupname}) for each group
* @param {String} the string to run regex
* @return {Object} an object with a property for each group having the group's match as the value
* @throws {RangeError} if `pattern` does not contain any group.
*
* @internal
*/
export function matchWithGroups(pattern: string, str: string): Record<string, string | null> {
const matches = str.match(pattern)
const groupNames = pattern.toString().match(/<(.+?)>/g)
if (groupNames == null) {
throw new RangeError('pattern must contain at least one group')
}
return groupNames
// remove the braces
.map((group) => {
const groupMatches = group.match(/<(.+)>/)
// @ts-expect-error - groupMatches should never be null
return groupMatches[1]
})
// create an object with a property for each group having the group's match as the value
.reduce((acc, curr, index) => {
if (matches && matches.length > index) {
acc[curr] = matches[index + 1]
} else {
acc[curr] = null
}
return acc
}, {} as Record<string, string | null>)
}
/*
* Based on
* https://github.com/fregante/supports-webp
*
* @internal
*/
export function isWebpSupported(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const image = new Image()
image.onerror = () => resolve(false)
image.onload = () => resolve(image.width === 1)
image.src = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='
}).catch(() => false)
}
// only `$root` of a component instance is our concern.
// we may face type errors if we use `ComponentPublicInstance` directly.
export function isCustomElement(vm: Pick<ComponentPublicInstance, '$root'>) {
return vm.$root != null && 'shadowRoot' in vm.$root.$options
}
export const isDefined = <T>(d: T | undefined): d is T => d !== undefined
/*
* Checks if a value is null or undefined.
* Based on
* https://github.com/lodash/lodash/blob/master/isNil.js
*
* @internal
*/
export const isNil = (value: unknown) => value === null || value === undefined
export function isFragment(vnode: VNode): boolean {
return vnode.type === Fragment
}
// TODO: replacement of vnode.tag test
export function isTag(vnode: VNode): boolean {
return vnode.type !== Comment &&
vnode.type !== Text &&
vnode.type !== Static
}
// references
// - https://github.com/vuejs/core/blob/1c525f75a3d17a6356d5f66765623c0ae7c0ebcc/packages/runtime-core/src/apiCreateApp.ts#L361
// - https://github.com/vuejs/core/blob/1c525f75a3d17a6356d5f66765623c0ae7c0ebcc/packages/runtime-core/src/component.ts#L1036-L1054
//
// we cannot access getExposeProxy since it is not exported from `vue`, though,
// its purpose seems to be one-time initialization of component.exposeProxy,
// which should have been done by this function call
export function getComponentFromVNode(
vnode: VNode
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ComponentPublicInstance | Record<string, any> | null | undefined {
if (!vnode) {
return undefined
}
const { component } = vnode
if (!component) {
return undefined
}
return (component.exposed && component.exposeProxy) || component.proxy
}
// Copies the context from a given app to another app.
//
// This function is necessary to programmatically mount a component; e.g.,
// Modal.
// Since Vue 3's app can mount only one component, we have to create a new app
// to mount another new component.
// If we create a new app with `createApp` API, no context (e.g., installed
// components, directives) is available on the new app.
// This function can copy the context from the host app to the new app.
//
// Depends on what Vue internally does: https://github.com/vuejs/core/blob/b775b71c788499ec7ee58bc2cf4cd04ed388e072/packages/runtime-core/src/apiCreateApp.ts#L170-L190
//
// This function also should take care of compatiblity with other plugins.
// We need a generic solution, though, it fixes compatiblity issues of
// individual plugins for now.
export function copyAppContext(src: App, dest: App) {
// replacing _context won't work because methods of app bypasses app._context
const { _context: srcContext } = src
const { _context: destContext } = dest
destContext.config = srcContext.config
destContext.mixins = srcContext.mixins
destContext.components = srcContext.components
destContext.directives = srcContext.directives
destContext.provides = srcContext.provides
// @ts-expect-error - optionsCache is internal field
destContext.optionsCache = srcContext.optionsCache
// @ts-expect-error - propsCache is internal field
destContext.propsCache = srcContext.propsCache
// @ts-expect-error - emitsCache is internal field
destContext.emitsCache = srcContext.emitsCache
// vue-i18n support: https://github.com/ntohq/buefy-next/issues/153
if ('__VUE_I18N_SYMBOL__' in src) {
dest.__VUE_I18N_SYMBOL__ = src.__VUE_I18N_SYMBOL__
}
}
/* Options for `translateTouchAsDragEvent`. */
export interface TranslateTouchAsDragEventOptions {
type: 'dragstart' | 'dragend' | 'drop' | 'dragover' | 'dragleave'
target?: Element
}
/*
* Translates a touch event as a drag event.
*
* `event` must be a touch event.
*
* `options` must be an object with the following properties:
* - `type`: new event type (required). must be one of the following:
* - `"dragstart"`
* - `"dragend"`
* - `"drop"`
* - `"dragover"`
* - `"dragleave"`
* - `target`: new target element (optional). `clientX` and `clientY` will be
* translated if `target` is different from `event.target`.
*
* This function only works with single-touch events for now.
*/
export const translateTouchAsDragEvent = (
event: TouchEvent,
options: TranslateTouchAsDragEventOptions
) => {
const { type, target } = options
let translateX = 0
let translateY = 0
if (target != null && target !== event.target) {
const baseRect = (event.target! as HTMLElement).getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
translateX = targetRect.left - baseRect.left
translateY = targetRect.top - baseRect.top
}
const touch = event.touches[0] || event.changedTouches[0]
return new DragEvent(type, {
dataTransfer: new DataTransfer(),
bubbles: true,
screenX: touch.screenX,
screenY: touch.screenY,
clientX: touch.clientX + translateX,
clientY: touch.clientY + translateY,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey
})
}