UNPKG

tippy.vue

Version:

Nesting-free Vue components for Tippy.js - a drop-in addition with no structural or css changes required

622 lines (613 loc) 20.1 kB
import tippy, { createSingleton } from 'tippy.js'; import { toRefs, computed, watch, defineComponent, h, ref, nextTick } from 'vue'; const _mode = Symbol("v-tippy mode"); const _tippy = Symbol("v-tippy instance"); function createOptions(value) { if (typeof value === 'string') { return { content: value }; } else if (typeof value === 'undefined') { return {}; } else { return value; } } const TippyDirective = { mounted(el, binding) { if (binding.value === undefined) { el[_mode] = 'target'; el.dataset.tippyTarget = binding.arg || ""; } else { el[_mode] = 'inline'; el[_tippy] = tippy(el, createOptions(binding.value)); } }, beforeUnmount(el) { if (el[_mode] === 'inline') { let tip = el[_tippy]; tip && tip.destroy(); } else { delete el.dataset.tippyTarget; } }, updated(el, binding) { if (el[_mode] === 'inline') { let tip = el[_tippy]; tip && tip.setProps(createOptions(binding.value)); } } }; /* eslint-disable @typescript-eslint/no-unused-vars */ const commonEmits = { mount: (instance) => true, show: (instance) => true, shown: (instance) => true, hidden: (instance) => true, hide: (instance) => true, trigger: (instance, event) => true, untrigger: (instance, event) => true, }; /** * Infers the plugin type to provide type hinting for the parameter */ function inferPlugin(plugin) { return plugin; } /** * Creates a plugin that exposes a Tippy.js option as a Vue prop * @param name The name of the Tippy.js option * @param type The type of the Vue property (e.g. `String`, `Boolean`, etc.) * @param def The default value, if any */ function optionPlugin(name, type, def) { return { props: { [name]: { type: type ? type : null, required: false, default: def, }, }, build(props, options) { if (props[name].value !== undefined) { options[name] = props[name].value; } } }; } function commonSetup(props, plugins, baseContext, tip, hooks) { const context = baseContext; let refs = toRefs(props); const tippyOptions = computed(() => { const options = {}; for (const plugin of plugins) { let buildFn = plugin.build; if (buildFn) buildFn(refs, options); } options.onShow = joinCallbacks(instance => context.emit("show", instance), hooks && hooks.onShow, options.onShow); options.onShown = joinCallbacks(instance => context.emit("shown", instance), hooks && hooks.onShown, options.onShown); options.onHidden = joinCallbacks(instance => context.emit("hidden", instance), hooks && hooks.onHidden, options.onHidden); options.onHide = joinCallbacks(instance => context.emit("hide", instance), hooks && hooks.onHide, options.onHide); options.onMount = joinCallbacks(instance => context.emit("mount", instance), hooks && hooks.onMount, options.onMount); options.onTrigger = joinCallbacks((instance, event) => context.emit("trigger", instance, event), hooks && hooks.onTrigger, options.onTrigger); options.onUntrigger = joinCallbacks((instance, event) => context.emit("untrigger", instance, event), hooks && hooks.onUntrigger, options.onUntrigger); return options; }); for (const plugin of plugins) { let setupFn = plugin.setup; if (setupFn) setupFn(refs, tip); } watch(tippyOptions, value => { if (tip.value) tip.value.setProps(value); }, { deep: true }); return { tippyOptions }; } function joinCallbacks(...callbacks) { return (...args) => { let result; for (let callback of callbacks) { if (callback) result = callback(...args); } return result; }; } /** * Extra options for tippy.js */ const extra = inferPlugin({ props: { extra: { type: Object, required: false, }, }, build(props, options) { Object.assign(options, props.extra.value || {}); } }); /** * Whether the tooltip should be enabled */ const enabled = inferPlugin({ props: { enabled: { type: Boolean, required: false, default: true, } }, setup(props, tip) { watch(props.enabled, value => { if (!tip.value) return; if (value) { tip.value.enable(); } else { tip.value.hide(); tip.value.disable(); } }); } }); /** * Where to place the tooltip relative to the target element */ const placement = optionPlugin("placement", String, 'top'); /** * Whether the tippy should be interactive. You don't need to specify a value for this property, its presence is * sufficient (e.g. `<tippy interactive>`). * * This is a shorthand for `interactive: true` in the `extra` property. */ const interactive = optionPlugin("interactive", Boolean); /** * Whether to hide the tooltip when the target element is clicked. Defaults to false when using the `'manual'` * trigger, otherwise defaults to true. */ const hideOnClick = optionPlugin("hideOnClick", Boolean); /** * Whether the tippy should *always* be appended to the `<body>`. You don't need to specify a value for this property, * its presence is sufficient (e.g. `<tippy on-body>`). * * Normally, tooltips will be appended to the document body element, *however*, interactive elements are appended * adjacent to their trigger, in the interest of maintaining keyboard focus order. * [more info](https://atomiks.github.io/tippyjs/v6/accessibility/#clipping-issues) * * This can cause zIndex issues, so sometimes it's necessary to put an interactive tooltip on the body element. * * This is a shorthand for `appendTo: () => document.body` in the `extra` property. (Note that you can't access * `document` directly in a vue template, so you would have to use a computed property if you wanted to set this in * `extra` yourself. */ const onBody = inferPlugin({ props: { onBody: { type: Boolean, required: false, }, }, build(props, options) { if (props.onBody.value === true) { options.appendTo = () => document.body; } } }); /** * The events that trigger the tooltip. Setting the trigger key in `extra` will override this property. */ const trigger = inferPlugin({ props: { trigger: { type: String, required: false, }, }, build(props, options) { if (props.trigger.value) { options.trigger = props.trigger.value; if (props.trigger.value === 'manual' && options.hideOnClick === undefined) { options.hideOnClick = false; } } } }); const delayPattern = /^([0-9]+)$|^([0-9]+|-)\s*,\s*([0-9]+|-)$/; function parseDelay(input) { if (typeof input === "string") { let match = input.match(delayPattern); if (match) { if (match[1]) { return parseFloat(match[1]); } else { return [ match[2] === '-' ? null : parseFloat(match[2]), match[3] === '-' ? null : parseFloat(match[3]) ]; } } else { return undefined; } } else { return input; } } /** * The delay when showing or hiding the tooltip. One of four formats: * - A number (or number string) representing the delay in milliseconds * - A string consisting of two comma-separated elements representing the show and hide delays, each of which is * either a number or a '-' * - An array of two `number | null` elements */ const delay = inferPlugin({ props: { delay: { type: [String, Number, Array], required: false, validator(value) { return parseDelay(value) !== undefined; } } }, build(props, options) { if (props.delay.value !== undefined) { options.delay = parseDelay(props.delay.value); } } }); /** * Only used when using the manual trigger. To show/hide when using another trigger, use `tippy().show()` and * `tippy().hide()` */ const visible = inferPlugin({ props: { visible: { type: Boolean, required: false, }, }, setup(props, tip) { watch(props.visible, value => { if (!tip.value || (props.trigger && props.trigger.value !== 'manual')) return; if (value) { tip.value.show(); } else { tip.value.hide(); } }); } }); /** * Tippy.js options that should be overridden by the individual instances. */ const overrides = inferPlugin({ props: { overrides: { type: Array, required: false, }, }, build(props, options) { const sOptions = options; sOptions.overrides = (sOptions.overrides || []).concat(props.overrides.value || []); } }); /** * The CSS transition to use when moving between instances within the singleton */ const moveTransition = optionPlugin("moveTransition", String); var builtin = /*#__PURE__*/Object.freeze({ __proto__: null, extra: extra, enabled: enabled, placement: placement, interactive: interactive, hideOnClick: hideOnClick, onBody: onBody, trigger: trigger, delay: delay, visible: visible, overrides: overrides, moveTransition: moveTransition }); const defaultTippyProps = [ visible, enabled, placement, onBody, interactive, trigger, hideOnClick, delay, extra, ]; const baseProps$1 = { /** * The v-tippy target name. Defaults to `""` (the default name used by `v-tippy`) */ target: { type: String, required: false, default: "" }, /** * Whether to perform a deep search for targets (using querySelector) or to only search for direct siblings. */ deepSearch: { type: Boolean, required: false, default: false }, singleton: { type: String, required: false, default: null, }, /** * Whether to eagerly render the content or only render when the tooltip is visible */ eager: { type: Boolean, required: false, default: false }, }; function createTippyComponent(...plugins) { let pluginProps = {}; for (const plugin of plugins) { Object.assign(pluginProps, plugin.props); } return defineComponent({ props: { ...baseProps$1, ...pluginProps }, /* eslint-disable @typescript-eslint/no-unused-vars */ emits: { attach: (instance) => true, ...commonEmits }, /* eslint-enable @typescript-eslint/no-unused-vars */ render() { return h('div', { 'tippy-missing-target': this.tippyTargetMissing ? '' : undefined, }, (this.$props.eager || this.singletonInstance || this.shouldRenderContent) && this.$slots.default ? this.$slots.default() : []); }, setup(props, context) { const tip = ref(); const singletonInstance = ref(); const tippyTargetMissing = ref(false); const shouldRenderContent = ref(false); const { tippyOptions } = commonSetup(props, plugins, context, tip, { onShow() { shouldRenderContent.value = true; }, onHidden() { shouldRenderContent.value = false; } }); return { tip, tippyOptions, singletonInstance, tippyTargetMissing, shouldRenderContent, }; }, methods: { attach() { // destroy old tip if (this.tip) { const tip = this.tip; this.tip = undefined; if (this.singletonInstance) { this.singletonInstance.remove(tip); this.singletonInstance = undefined; } tip.destroy(); } // find the target let target; if (this.target === '_parent') { target = this.$el.parentElement; } else if (this.deepSearch) { target = this.$el.parentElement.querySelector(`[data-tippy-target="${this.target}"]`); } else { const targetValue = this.target; target = findElement(this.$el, { test(el) { let a = el; return a && a.dataset && a.dataset.tippyTarget === targetValue; } }); } this.tippyTargetMissing = !target; if (!target) { throw new Error(`Unable to find tippy target named '${this.target}'`); } // find the singleton if (this.singleton != null) { const targetValue = this.singleton; const singletonElement = findElement(this.$el, { test(el) { let a = el; return a && a.dataset && a.dataset.tippySingleton === targetValue; }, recurse: true }); this.singletonInstance = singletonElement ? singletonElement._tippySingleton : undefined; } else { this.singletonInstance = undefined; } // create and bind tip this.tip = tippy(target, this.tippyOptions); if (!this.tip) { throw new Error(`Unable to create tippy instance`); } this.tip.setContent(this.$el); this.singletonInstance && this.singletonInstance.add(this.tip); if (this.enabled === false) { this.tip.disable(); } if (this.trigger === 'manual' && this.visible === true) { this.tip.show(); } this.$emit("attach", this.tip); } }, async mounted() { await nextTick(); this.attach(); }, beforeUnmount() { this.tip && this.tip.destroy(); }, }); } /** * @param start the element to start at. will not test the starting element or any of its parents * @param search the search parameters to use */ function findElement(start, search) { let found = null; let current = start; do { found = findSibling(current, search.test, search.selftest === undefined ? false : search.selftest); current = current.parentElement; } while (search.recurse && current && !found); return found; } function findSibling(element, test, testSelf) { if (testSelf && test(element)) { return element; } for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling) { if (test(sibling)) return sibling; } for (let sibling = element.nextElementSibling; sibling; sibling = sibling.nextElementSibling) { if (test(sibling)) return sibling; } return null; } const defaultTippySingletonProps = [ overrides, moveTransition, enabled, placement, onBody, interactive, trigger, hideOnClick, delay, extra, ]; const baseProps = { /** * The singleton name. Defaults to `""` (the default name used by `<tippy singleton>`) */ name: { type: String, required: false, default: "" }, }; function createTippySingletonComponent(...plugins) { let pluginProps = {}; for (const plugin of plugins) { Object.assign(pluginProps, plugin.props); } return defineComponent({ props: { ...baseProps, ...pluginProps }, /* eslint-disable @typescript-eslint/no-unused-vars */ emits: { add: (instance) => true, remove: (instance) => true, ...commonEmits }, /* eslint-enable @typescript-eslint/no-unused-vars */ render() { return h('div', { 'style': 'display: none;', 'data-tippy-singleton': this.name }, []); }, setup(props, context) { const singleton = ref(); const { tippyOptions } = commonSetup(props, plugins, context, singleton); const instances = ref([]); return { tippyOptions, instances, singleton, }; }, mounted() { this.$el._tippySingleton = this; this.singleton = createSingleton(this.instances, this.tippyOptions); if (this.enabled === false) { this.singleton.disable(); } }, beforeUnmount() { this.singleton && this.singleton.destroy(); }, methods: { remove(instance) { const index = this.instances.indexOf(instance); if (index === -1) { return; } this.instances.splice(index, 1); this.$emit('remove', instance); this.singleton && this.singleton.setInstances(this.instances); }, add(instance) { if (this.instances.indexOf(instance) !== -1) { return; } this.instances.push(instance); this.$emit('add', instance); this.singleton && this.singleton.setInstances(this.instances); } } }); } function install(app, options) { if (options && options.tippyDefaults) { tippy.setDefaultProps(options.tippyDefaults); } app.directive('tippy', TippyDirective); app.component('tippy', createTippyComponent(...(options && options.tippyProps || defaultTippyProps))); app.component('tippy-singleton', createTippySingletonComponent(...(options && options.tippySingletonProps || defaultTippySingletonProps))); } const TippyPlugin = { install }; const Tippy = createTippyComponent(...defaultTippyProps); const TippySingleton = createTippySingletonComponent(...defaultTippySingletonProps); export { Tippy, TippyDirective, TippyPlugin, TippySingleton, createTippyComponent, createTippySingletonComponent, defaultTippyProps, defaultTippySingletonProps, inferPlugin, install, optionPlugin, builtin as props }; //# sourceMappingURL=index.esm.js.map