Коммит accf817e создал по автору Alex Buijs's avatar Alex Buijs Зафиксировано автором Justin Ho Tuan Duong
Просмотр файлов

Add truncated text component

This is a component that truncates text based on the number of lines.

Changelog: added
владелец eb38b888
...@@ -4,6 +4,7 @@ import { __ } from '~/locale'; ...@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html'; import SafeHtml from '~/vue_shared/directives/safe_html';
import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { renderGFM } from '~/behaviors/markdown/render_gfm';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
import { REPORTED_CONTENT_I18N } from '../constants'; import { REPORTED_CONTENT_I18N } from '../constants';
export default { export default {
...@@ -15,6 +16,7 @@ export default { ...@@ -15,6 +16,7 @@ export default {
GlLink, GlLink,
GlAvatar, GlAvatar,
TimeAgoTooltip, TimeAgoTooltip,
TruncatedText,
}, },
modalId: 'abuse-report-screenshot-modal', modalId: 'abuse-report-screenshot-modal',
directives: { directives: {
...@@ -107,11 +109,13 @@ export default { ...@@ -107,11 +109,13 @@ export default {
footer-class="gl-bg-white js-test-card-footer" footer-class="gl-bg-white js-test-card-footer"
> >
<template v-if="report.content" #header> <template v-if="report.content" #header>
<div <truncated-text>
ref="gfmContent" <div
v-safe-html:[$options.safeHtmlConfig]="report.content" ref="gfmContent"
class="md" v-safe-html:[$options.safeHtmlConfig]="report.content"
></div> class="md"
></div>
</truncated-text>
</template> </template>
{{ $options.i18n.reportedBy }} {{ $options.i18n.reportedBy }}
<template #footer> <template #footer>
......
import { __ } from '~/locale';
export const SHOW_MORE = __('Show more');
export const SHOW_LESS = __('Show less');
export const STATES = {
INITIAL: 'initial',
TRUNCATED: 'truncated',
EXTENDED: 'extended',
};
import { escape } from 'lodash';
import TruncatedText from './truncated_text.vue';
export default {
component: TruncatedText,
title: 'vue_shared/truncated_text',
};
const Template = (args, { argTypes }) => ({
components: { TruncatedText },
props: Object.keys(argTypes),
template: `
<truncated-text v-bind="$props">
<template v-if="${'default' in args}" v-slot>
<span style="white-space: pre-line;">${escape(args.default)}</span>
</template>
</truncated-text>
`,
});
export const Default = Template.bind({});
Default.args = {
lines: 3,
mobileLines: 10,
default: [...Array(15)].map((_, i) => `line ${i + 1}`).join('\n'),
};
<script>
import { GlResizeObserverDirective, GlButton } from '@gitlab/ui';
import { STATES, SHOW_MORE, SHOW_LESS } from './constants';
export default {
name: 'TruncatedText',
components: {
GlButton,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
props: {
lines: {
type: Number,
required: false,
default: 3,
},
mobileLines: {
type: Number,
required: false,
default: 10,
},
},
data() {
return {
state: STATES.INITIAL,
};
},
computed: {
showTruncationToggle() {
return this.state !== STATES.INITIAL;
},
truncationToggleText() {
if (this.state === STATES.TRUNCATED) {
return SHOW_MORE;
}
return SHOW_LESS;
},
styleObject() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return { '--lines': this.lines, '--mobile-lines': this.mobileLines };
},
isTruncated() {
return this.state === STATES.EXTENDED ? null : 'gl-truncate-text-by-line gl-overflow-hidden';
},
},
methods: {
onResize({ target }) {
if (target.scrollHeight > target.offsetHeight) {
this.state = STATES.TRUNCATED;
} else if (this.state === STATES.TRUNCATED) {
this.state = STATES.INITIAL;
}
},
toggleTruncation() {
if (this.state === STATES.TRUNCATED) {
this.state = STATES.EXTENDED;
} else if (this.state === STATES.EXTENDED) {
this.state = STATES.TRUNCATED;
}
},
},
};
</script>
<template>
<section>
<article
ref="content"
v-gl-resize-observer="onResize"
:class="isTruncated"
:style="styleObject"
>
<slot></slot>
</article>
<gl-button v-if="showTruncationToggle" variant="link" @click="toggleTruncation">{{
truncationToggleText
}}</gl-button>
</section>
</template>
...@@ -153,3 +153,21 @@ ...@@ -153,3 +153,21 @@
.gl-fill-red-500 { .gl-fill-red-500 {
fill: $red-500; fill: $red-500;
} }
/**
Note: used by app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab/-/issues/408643
Although this solution uses vendor-prefixes, it is supported by all browsers and it is
currently the only way to truncate text by lines. See https://caniuse.com/css-line-clamp
**/
.gl-truncate-text-by-line {
// stylelint-disable-next-line value-no-vendor-prefix
display: -webkit-box;
-webkit-line-clamp: var(--lines);
-webkit-box-orient: vertical;
@include gl-media-breakpoint-down(sm) {
-webkit-line-clamp: var(--mobile-lines);
}
}
...@@ -3,6 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; ...@@ -3,6 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { renderGFM } from '~/behaviors/markdown/render_gfm';
import ReportedContent from '~/admin/abuse_report/components/reported_content.vue'; import ReportedContent from '~/admin/abuse_report/components/reported_content.vue';
import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { REPORTED_CONTENT_I18N } from '~/admin/abuse_report/constants'; import { REPORTED_CONTENT_I18N } from '~/admin/abuse_report/constants';
import { mockAbuseReport } from '../mock_data'; import { mockAbuseReport } from '../mock_data';
...@@ -21,6 +22,7 @@ describe('ReportedContent', () => { ...@@ -21,6 +22,7 @@ describe('ReportedContent', () => {
const findModal = () => wrapper.findComponent(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findCard = () => wrapper.findComponent(GlCard); const findCard = () => wrapper.findComponent(GlCard);
const findCardHeader = () => findCard().find('.js-test-card-header'); const findCardHeader = () => findCard().find('.js-test-card-header');
const findTruncatedText = () => findCardHeader().findComponent(TruncatedText);
const findCardBody = () => findCard().find('.js-test-card-body'); const findCardBody = () => findCard().find('.js-test-card-body');
const findCardFooter = () => findCard().find('.js-test-card-footer'); const findCardFooter = () => findCard().find('.js-test-card-footer');
const findAvatar = () => findCardFooter().findComponent(GlAvatar); const findAvatar = () => findCardFooter().findComponent(GlAvatar);
...@@ -38,6 +40,7 @@ describe('ReportedContent', () => { ...@@ -38,6 +40,7 @@ describe('ReportedContent', () => {
GlSprintf, GlSprintf,
GlButton, GlButton,
GlCard, GlCard,
TruncatedText,
}, },
}); });
}; };
...@@ -136,7 +139,9 @@ describe('ReportedContent', () => { ...@@ -136,7 +139,9 @@ describe('ReportedContent', () => {
describe('rendering the card header', () => { describe('rendering the card header', () => {
describe('when the report contains the reported content', () => { describe('when the report contains the reported content', () => {
it('renders the content', () => { it('renders the content', () => {
expect(findCardHeader().text()).toBe(report.content.replace(/<\/?[^>]+>/g, '')); const dummyElement = document.createElement('div');
dummyElement.innerHTML = report.content;
expect(findTruncatedText().text()).toBe(dummyElement.textContent);
}); });
it('renders gfm', () => { it('renders gfm', () => {
......
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { __ } from '~/locale';
import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('TruncatedText', () => {
let wrapper;
const findContent = () => wrapper.findComponent({ ref: 'content' }).element;
const findButton = () => wrapper.findComponent(GlButton);
const createComponent = (propsData = {}) => {
wrapper = shallowMount(TruncatedText, {
propsData,
directives: {
GlResizeObserver: createMockDirective('gl-resize-observer'),
},
stubs: {
GlButton,
},
});
};
beforeEach(() => {
createComponent();
});
describe('when mounted', () => {
it('the content has class `gl-truncate-text-by-line`', () => {
expect(findContent().classList).toContain('gl-truncate-text-by-line');
});
it('the content has style variables for `lines` and `mobile-lines` with the correct values', () => {
const { style } = findContent();
expect(style).toContain('--lines');
expect(style.getPropertyValue('--lines')).toBe('3');
expect(style).toContain('--mobile-lines');
expect(style.getPropertyValue('--mobile-lines')).toBe('10');
});
it('the button is not visible', () => {
expect(findButton().exists()).toBe(false);
});
});
describe('when mounted with a value for the lines property', () => {
const lines = 4;
beforeEach(() => {
createComponent({ lines });
});
it('the lines variable has the value of the passed property', () => {
expect(findContent().style.getPropertyValue('--lines')).toBe(lines.toString());
});
});
describe('when mounted with a value for the mobileLines property', () => {
const mobileLines = 4;
beforeEach(() => {
createComponent({ mobileLines });
});
it('the lines variable has the value of the passed property', () => {
expect(findContent().style.getPropertyValue('--mobile-lines')).toBe(mobileLines.toString());
});
});
describe('when resizing and the scroll height is smaller than the offset height', () => {
beforeEach(() => {
getBinding(findContent(), 'gl-resize-observer').value({
target: { scrollHeight: 10, offsetHeight: 20 },
});
});
it('the button remains invisible', () => {
expect(findButton().exists()).toBe(false);
});
});
describe('when resizing and the scroll height is greater than the offset height', () => {
beforeEach(() => {
getBinding(findContent(), 'gl-resize-observer').value({
target: { scrollHeight: 20, offsetHeight: 10 },
});
});
it('the button becomes visible', () => {
expect(findButton().exists()).toBe(true);
});
it('the button text says "show more"', () => {
expect(findButton().text()).toBe(__('Show more'));
});
describe('clicking the button', () => {
beforeEach(() => {
findButton().trigger('click');
});
it('removes the `gl-truncate-text-by-line` class on the content', () => {
expect(findContent().classList).not.toContain('gl-truncate-text-by-line');
});
it('toggles the button text to "Show less"', () => {
expect(findButton().text()).toBe(__('Show less'));
});
});
});
});
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать