Commit a8dbf7c3 authored by Miguel Rincon's avatar Miguel Rincon Committed by Paul Slaughter

Adapt to size changes when displaying tooltips

This change updates the "tooltip-on-truncate" component to respond to
changes in the component size.

Previously the component was only detected as overflowing once and
didn't respond to changes in its size.

Changelog: changed
parent a385aa11
<script>
import { GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { isFunction } from 'lodash';
import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui';
import { isFunction, debounce } from 'lodash';
import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
const UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS = 300;
export default {
directives: {
GlTooltip,
GlTooltip: GlTooltipDirective,
GlResizeObserver: GlResizeObserverDirective,
},
props: {
title: {
......@@ -26,15 +29,33 @@ export default {
},
data() {
return {
showTooltip: false,
tooltipDisabled: true,
};
},
computed: {
classes() {
if (this.tooltipDisabled) {
return '';
}
return 'js-show-tooltip';
},
tooltip() {
return {
title: this.title,
placement: this.placement,
disabled: this.tooltipDisabled,
};
},
},
watch: {
title() {
// Wait on $nextTick in case of slot width changes
// Wait on $nextTick in case the slot width changes
this.$nextTick(this.updateTooltip);
},
},
created() {
this.updateTooltipDebounced = debounce(this.updateTooltip, UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS);
},
mounted() {
this.updateTooltip();
},
......@@ -45,25 +66,20 @@ export default {
} else if (this.truncateTarget === 'child') {
return this.$el.childNodes[0];
}
return this.$el;
},
updateTooltip() {
const target = this.selectTarget();
this.showTooltip = hasHorizontalOverflow(target);
this.tooltipDisabled = !hasHorizontalOverflow(this.selectTarget());
},
onResize() {
this.updateTooltipDebounced();
},
},
};
</script>
<template>
<span
v-if="showTooltip"
v-gl-tooltip="{ placement }"
:title="title"
class="js-show-tooltip gl-min-w-0"
>
<span v-gl-tooltip="tooltip" v-gl-resize-observer="onResize" :class="classes" class="gl-min-w-0">
<slot></slot>
</span>
<span v-else class="gl-min-w-0"> <slot></slot> </span>
</template>
import { mount, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
const DUMMY_TEXT = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do';
const MOCK_TITLE = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do';
const SHORT_TITLE = 'my-text';
const createChildElement = () => `<a href="#">${DUMMY_TEXT}</a>`;
const createChildElement = () => `<a href="#">${MOCK_TITLE}</a>`;
jest.mock('~/lib/utils/dom_utils', () => ({
hasHorizontalOverflow: jest.fn(() => {
...jest.requireActual('~/lib/utils/dom_utils'),
hasHorizontalOverflow: jest.fn().mockImplementation(() => {
throw new Error('this needs to be mocked');
}),
}));
jest.mock('@gitlab/ui', () => ({
GlTooltipDirective: {
bind(el, binding) {
el.classList.add('gl-tooltip');
el.setAttribute('data-original-title', el.title);
el.dataset.placement = binding.value.placement;
},
},
}));
describe('TooltipOnTruncate component', () => {
let wrapper;
......@@ -27,15 +22,31 @@ describe('TooltipOnTruncate component', () => {
const createComponent = ({ propsData, ...options } = {}) => {
wrapper = shallowMount(TooltipOnTruncate, {
attachTo: document.body,
propsData: {
title: MOCK_TITLE,
...propsData,
},
slots: {
default: [MOCK_TITLE],
},
directives: {
GlTooltip: createMockDirective(),
GlResizeObserver: createMockDirective(),
},
...options,
});
};
const createWrappedComponent = ({ propsData, ...options }) => {
const WrappedTooltipOnTruncate = {
...TooltipOnTruncate,
directives: {
...TooltipOnTruncate.directives,
GlTooltip: createMockDirective(),
GlResizeObserver: createMockDirective(),
},
};
// set a parent around the tested component
parent = mount(
{
......@@ -43,74 +54,85 @@ describe('TooltipOnTruncate component', () => {
title: { default: '' },
},
template: `
<TooltipOnTruncate :title="title" truncate-target="child">
<div>{{title}}</div>
</TooltipOnTruncate>
<TooltipOnTruncate :title="title" truncate-target="child">
<div>{{title}}</div>
</TooltipOnTruncate>
`,
components: {
TooltipOnTruncate,
TooltipOnTruncate: WrappedTooltipOnTruncate,
},
},
{
propsData: { ...propsData },
attachTo: document.body,
...options,
},
);
wrapper = parent.find(TooltipOnTruncate);
wrapper = parent.find(WrappedTooltipOnTruncate);
};
const hasTooltip = () => wrapper.classes('gl-tooltip');
const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip')?.value;
const resize = async ({ truncate }) => {
hasHorizontalOverflow.mockReturnValueOnce(truncate);
getBinding(wrapper.element, 'gl-resize-observer').value();
await nextTick();
};
afterEach(() => {
wrapper.destroy();
});
describe('with default target', () => {
it('renders tooltip if truncated', () => {
describe('when truncated', () => {
beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
title: DUMMY_TEXT,
},
slots: {
default: [DUMMY_TEXT],
},
});
createComponent();
});
return wrapper.vm.$nextTick().then(() => {
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element);
expect(hasTooltip()).toBe(true);
expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT);
expect(wrapper.attributes('data-placement')).toEqual('top');
it('renders tooltip', async () => {
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
expect(getTooltipValue()).toMatchObject({
title: MOCK_TITLE,
placement: 'top',
disabled: false,
});
expect(wrapper.classes('js-show-tooltip')).toBe(true);
});
});
it('does not render tooltip if normal', () => {
describe('with default target', () => {
beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
createComponent({
propsData: {
title: DUMMY_TEXT,
},
slots: {
default: [DUMMY_TEXT],
},
createComponent();
});
it('does not render tooltip if not truncated', () => {
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
expect(getTooltipValue()).toMatchObject({
disabled: true,
});
expect(wrapper.classes('js-show-tooltip')).toBe(false);
});
return wrapper.vm.$nextTick().then(() => {
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element);
expect(hasTooltip()).toBe(false);
it('renders tooltip on resize', async () => {
await resize({ truncate: true });
expect(getTooltipValue()).toMatchObject({
disabled: false,
});
await resize({ truncate: false });
expect(getTooltipValue()).toMatchObject({
disabled: true,
});
});
});
describe('with child target', () => {
it('renders tooltip if truncated', () => {
it('renders tooltip if truncated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
title: DUMMY_TEXT,
truncateTarget: 'child',
},
slots: {
......@@ -118,13 +140,18 @@ describe('TooltipOnTruncate component', () => {
},
});
return wrapper.vm.$nextTick().then(() => {
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
expect(hasTooltip()).toBe(true);
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]);
await nextTick();
expect(getTooltipValue()).toMatchObject({
title: MOCK_TITLE,
placement: 'top',
disabled: false,
});
});
it('does not render tooltip if normal', () => {
it('does not render tooltip if normal', async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
createComponent({
propsData: {
......@@ -135,19 +162,21 @@ describe('TooltipOnTruncate component', () => {
},
});
return wrapper.vm.$nextTick().then(() => {
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
expect(hasTooltip()).toBe(false);
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]);
await nextTick();
expect(getTooltipValue()).toMatchObject({
disabled: true,
});
});
});
describe('with fn target', () => {
it('renders tooltip if truncated', () => {
it('renders tooltip if truncated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
title: DUMMY_TEXT,
truncateTarget: (el) => el.childNodes[1],
},
slots: {
......@@ -155,93 +184,97 @@ describe('TooltipOnTruncate component', () => {
},
});
return wrapper.vm.$nextTick().then(() => {
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[1]);
expect(hasTooltip()).toBe(true);
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[1]);
await nextTick();
expect(getTooltipValue()).toMatchObject({
disabled: false,
});
});
});
describe('placement', () => {
it('sets data-placement when tooltip is rendered', () => {
const placement = 'bottom';
it('sets placement when tooltip is rendered', () => {
const mockPlacement = 'bottom';
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
placement,
},
slots: {
default: DUMMY_TEXT,
placement: mockPlacement,
},
});
return wrapper.vm.$nextTick().then(() => {
expect(hasTooltip()).toBe(true);
expect(wrapper.attributes('data-placement')).toEqual(placement);
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
expect(getTooltipValue()).toMatchObject({
placement: mockPlacement,
});
});
});
describe('updates when title and slot content changes', () => {
describe('is initialized with a long text', () => {
beforeEach(() => {
beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createWrappedComponent({
propsData: { title: DUMMY_TEXT },
propsData: { title: MOCK_TITLE },
});
return parent.vm.$nextTick();
await nextTick();
});
it('renders tooltip', () => {
expect(hasTooltip()).toBe(true);
expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT);
expect(wrapper.attributes('data-placement')).toEqual('top');
expect(getTooltipValue()).toMatchObject({
title: MOCK_TITLE,
placement: 'top',
disabled: false,
});
});
it('does not render tooltip after updated to a short text', () => {
it('does not render tooltip after updated to a short text', async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
parent.setProps({
title: 'new-text',
title: SHORT_TITLE,
});
return wrapper.vm
.$nextTick()
.then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
.then(() => {
expect(hasTooltip()).toBe(false);
});
await nextTick();
await nextTick(); // wait 2 times to get an updated slot
expect(getTooltipValue()).toMatchObject({
title: SHORT_TITLE,
disabled: true,
});
});
});
describe('is initialized with a short text', () => {
beforeEach(() => {
describe('is initialized with a short text that does not overflow', () => {
beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
createWrappedComponent({
propsData: { title: DUMMY_TEXT },
propsData: { title: MOCK_TITLE },
});
return wrapper.vm.$nextTick();
await nextTick();
});
it('does not render tooltip', () => {
expect(hasTooltip()).toBe(false);
expect(getTooltipValue()).toMatchObject({
title: MOCK_TITLE,
disabled: true,
});
});
it('renders tooltip after text is updated', () => {
it('renders tooltip after text is updated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
const newText = 'new-text';
parent.setProps({
title: newText,
title: SHORT_TITLE,
});
return wrapper.vm
.$nextTick()
.then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
.then(() => {
expect(hasTooltip()).toBe(true);
expect(wrapper.attributes('data-original-title')).toEqual(newText);
expect(wrapper.attributes('data-placement')).toEqual('top');
});
await nextTick();
await nextTick(); // wait 2 times to get an updated slot
expect(getTooltipValue()).toMatchObject({
title: SHORT_TITLE,
disabled: false,
});
});
});
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment