UNPKG

buefy

Version:

Lightweight UI components for Vue.js (v3) based on Bulma

528 lines (487 loc) 17.1 kB
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 }) }