Commit a0ab3d66 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '343977-truncate-responsive' into 'master'

Adapt to size changes when displaying tooltips

See merge request gitlab-org/gitlab!75020
parents 6aedb926 a8dbf7c3
<script> <script>
import { GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui';
import { isFunction } from 'lodash'; import { isFunction, debounce } from 'lodash';
import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
const UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS = 300;
export default { export default {
directives: { directives: {
GlTooltip, GlTooltip: GlTooltipDirective,
GlResizeObserver: GlResizeObserverDirective,
}, },
props: { props: {
title: { title: {
...@@ -26,15 +29,33 @@ export default { ...@@ -26,15 +29,33 @@ export default {
}, },
data() { data() {
return { 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: { watch: {
title() { title() {
// Wait on $nextTick in case of slot width changes // Wait on $nextTick in case the slot width changes
this.$nextTick(this.updateTooltip); this.$nextTick(this.updateTooltip);
}, },
}, },
created() {
this.updateTooltipDebounced = debounce(this.updateTooltip, UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS);
},
mounted() { mounted() {
this.updateTooltip(); this.updateTooltip();
}, },
...@@ -45,25 +66,20 @@ export default { ...@@ -45,25 +66,20 @@ export default {
} else if (this.truncateTarget === 'child') { } else if (this.truncateTarget === 'child') {
return this.$el.childNodes[0]; return this.$el.childNodes[0];
} }
return this.$el; return this.$el;
}, },
updateTooltip() { updateTooltip() {
const target = this.selectTarget(); this.tooltipDisabled = !hasHorizontalOverflow(this.selectTarget());
this.showTooltip = hasHorizontalOverflow(target); },
onResize() {
this.updateTooltipDebounced();
}, },
}, },
}; };
</script> </script>
<template> <template>
<span <span v-gl-tooltip="tooltip" v-gl-resize-observer="onResize" :class="classes" class="gl-min-w-0">
v-if="showTooltip"
v-gl-tooltip="{ placement }"
:title="title"
class="js-show-tooltip gl-min-w-0"
>
<slot></slot> <slot></slot>
</span> </span>
<span v-else class="gl-min-w-0"> <slot></slot> </span>
</template> </template>
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; 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', () => ({ 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'); 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', () => { describe('TooltipOnTruncate component', () => {
let wrapper; let wrapper;
...@@ -27,15 +22,31 @@ describe('TooltipOnTruncate component', () => { ...@@ -27,15 +22,31 @@ describe('TooltipOnTruncate component', () => {
const createComponent = ({ propsData, ...options } = {}) => { const createComponent = ({ propsData, ...options } = {}) => {
wrapper = shallowMount(TooltipOnTruncate, { wrapper = shallowMount(TooltipOnTruncate, {
attachTo: document.body,
propsData: { propsData: {
title: MOCK_TITLE,
...propsData, ...propsData,
}, },
slots: {
default: [MOCK_TITLE],
},
directives: {
GlTooltip: createMockDirective(),
GlResizeObserver: createMockDirective(),
},
...options, ...options,
}); });
}; };
const createWrappedComponent = ({ propsData, ...options }) => { const createWrappedComponent = ({ propsData, ...options }) => {
const WrappedTooltipOnTruncate = {
...TooltipOnTruncate,
directives: {
...TooltipOnTruncate.directives,
GlTooltip: createMockDirective(),
GlResizeObserver: createMockDirective(),
},
};
// set a parent around the tested component // set a parent around the tested component
parent = mount( parent = mount(
{ {
...@@ -48,69 +59,80 @@ describe('TooltipOnTruncate component', () => { ...@@ -48,69 +59,80 @@ describe('TooltipOnTruncate component', () => {
</TooltipOnTruncate> </TooltipOnTruncate>
`, `,
components: { components: {
TooltipOnTruncate, TooltipOnTruncate: WrappedTooltipOnTruncate,
}, },
}, },
{ {
propsData: { ...propsData }, propsData: { ...propsData },
attachTo: document.body,
...options, ...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(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('with default target', () => { describe('when truncated', () => {
it('renders tooltip if truncated', () => { beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(true); hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({ createComponent();
propsData: {
title: DUMMY_TEXT,
},
slots: {
default: [DUMMY_TEXT],
},
}); });
return wrapper.vm.$nextTick().then(() => { it('renders tooltip', async () => {
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
expect(hasTooltip()).toBe(true); expect(getTooltipValue()).toMatchObject({
expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); title: MOCK_TITLE,
expect(wrapper.attributes('data-placement')).toEqual('top'); 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); hasHorizontalOverflow.mockReturnValueOnce(false);
createComponent({ createComponent();
propsData: {
title: DUMMY_TEXT,
},
slots: {
default: [DUMMY_TEXT],
},
}); });
return wrapper.vm.$nextTick().then(() => { it('does not render tooltip if not truncated', () => {
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
expect(hasTooltip()).toBe(false); expect(getTooltipValue()).toMatchObject({
disabled: true,
});
expect(wrapper.classes('js-show-tooltip')).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', () => { describe('with child target', () => {
it('renders tooltip if truncated', () => { it('renders tooltip if truncated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true); hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({ createComponent({
propsData: { propsData: {
title: DUMMY_TEXT,
truncateTarget: 'child', truncateTarget: 'child',
}, },
slots: { slots: {
...@@ -118,13 +140,18 @@ describe('TooltipOnTruncate component', () => { ...@@ -118,13 +140,18 @@ describe('TooltipOnTruncate component', () => {
}, },
}); });
return wrapper.vm.$nextTick().then(() => { expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]);
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
expect(hasTooltip()).toBe(true); 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); hasHorizontalOverflow.mockReturnValueOnce(false);
createComponent({ createComponent({
propsData: { propsData: {
...@@ -135,19 +162,21 @@ describe('TooltipOnTruncate component', () => { ...@@ -135,19 +162,21 @@ describe('TooltipOnTruncate component', () => {
}, },
}); });
return wrapper.vm.$nextTick().then(() => { expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]);
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
expect(hasTooltip()).toBe(false); await nextTick();
expect(getTooltipValue()).toMatchObject({
disabled: true,
}); });
}); });
}); });
describe('with fn target', () => { describe('with fn target', () => {
it('renders tooltip if truncated', () => { it('renders tooltip if truncated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true); hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({ createComponent({
propsData: { propsData: {
title: DUMMY_TEXT,
truncateTarget: (el) => el.childNodes[1], truncateTarget: (el) => el.childNodes[1],
}, },
slots: { slots: {
...@@ -155,92 +184,96 @@ describe('TooltipOnTruncate component', () => { ...@@ -155,92 +184,96 @@ describe('TooltipOnTruncate component', () => {
}, },
}); });
return wrapper.vm.$nextTick().then(() => { expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[1]);
expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[1]);
expect(hasTooltip()).toBe(true); await nextTick();
expect(getTooltipValue()).toMatchObject({
disabled: false,
}); });
}); });
}); });
describe('placement', () => { describe('placement', () => {
it('sets data-placement when tooltip is rendered', () => { it('sets placement when tooltip is rendered', () => {
const placement = 'bottom'; const mockPlacement = 'bottom';
hasHorizontalOverflow.mockReturnValueOnce(true); hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({ createComponent({
propsData: { propsData: {
placement, placement: mockPlacement,
},
slots: {
default: DUMMY_TEXT,
}, },
}); });
return wrapper.vm.$nextTick().then(() => { expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
expect(hasTooltip()).toBe(true); expect(getTooltipValue()).toMatchObject({
expect(wrapper.attributes('data-placement')).toEqual(placement); placement: mockPlacement,
}); });
}); });
}); });
describe('updates when title and slot content changes', () => { describe('updates when title and slot content changes', () => {
describe('is initialized with a long text', () => { describe('is initialized with a long text', () => {
beforeEach(() => { beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(true); hasHorizontalOverflow.mockReturnValueOnce(true);
createWrappedComponent({ createWrappedComponent({
propsData: { title: DUMMY_TEXT }, propsData: { title: MOCK_TITLE },
}); });
return parent.vm.$nextTick(); await nextTick();
}); });
it('renders tooltip', () => { it('renders tooltip', () => {
expect(hasTooltip()).toBe(true); expect(getTooltipValue()).toMatchObject({
expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); title: MOCK_TITLE,
expect(wrapper.attributes('data-placement')).toEqual('top'); 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); hasHorizontalOverflow.mockReturnValueOnce(false);
parent.setProps({ parent.setProps({
title: 'new-text', title: SHORT_TITLE,
}); });
return wrapper.vm await nextTick();
.$nextTick() await nextTick(); // wait 2 times to get an updated slot
.then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
.then(() => { expect(getTooltipValue()).toMatchObject({
expect(hasTooltip()).toBe(false); title: SHORT_TITLE,
disabled: true,
}); });
}); });
}); });
describe('is initialized with a short text', () => { describe('is initialized with a short text that does not overflow', () => {
beforeEach(() => { beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(false); hasHorizontalOverflow.mockReturnValueOnce(false);
createWrappedComponent({ createWrappedComponent({
propsData: { title: DUMMY_TEXT }, propsData: { title: MOCK_TITLE },
}); });
return wrapper.vm.$nextTick(); await nextTick();
}); });
it('does not render tooltip', () => { 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); hasHorizontalOverflow.mockReturnValueOnce(true);
const newText = 'new-text';
parent.setProps({ parent.setProps({
title: newText, title: SHORT_TITLE,
}); });
return wrapper.vm await nextTick();
.$nextTick() await nextTick(); // wait 2 times to get an updated slot
.then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
.then(() => { expect(getTooltipValue()).toMatchObject({
expect(hasTooltip()).toBe(true); title: SHORT_TITLE,
expect(wrapper.attributes('data-original-title')).toEqual(newText); disabled: false,
expect(wrapper.attributes('data-placement')).toEqual('top');
}); });
}); });
}); });
......
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