Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ellipsizeText property to grid and column for automatically ellipsizing long text #1567

Merged
merged 10 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/good-eggs-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@sl-design-system/grid': patch
---

Add `ellipsizeText` property to grid and column

When set on either `<sl-grid>` or `<sl-grid-column>` (or any of their variants), the `ellipsizeText` property
will render the table data using the `<sl-ellipsize-text>` component, which truncates text with an ellipsis when it
overflows its container. This is useful for tables with long text that would otherwise cause row height to grow.
The component also automatically adds a tooltip to the truncated text so that it can still be viewed.
5 changes: 5 additions & 0 deletions .changeset/spicy-mangos-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sl-design-system/ellipsize-text': patch
---

New utility `<sl-ellipsize-text>` component
1 change: 1 addition & 0 deletions packages/components/ellipsize-text/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './src/ellipsize-text.js';
49 changes: 49 additions & 0 deletions packages/components/ellipsize-text/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@sl-design-system/ellipsize-text",
"version": "0.0.0",
"description": "Utility component that ellipsizes text if it doesn't fit",
"license": "Apache-2.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"repository": {
"type": "git",
"url": "https://github.com/sl-design-system/components.git",
"directory": "packages/components/ellipsize-text"
},
"homepage": "https://sanomalearning.design/components/ellipsize-text",
"bugs": {
"url": "https://github.com/sl-design-system/components/issues"
},
"type": "module",
"main": "./index.js",
"module": "./index.js",
"types": "./index.d.ts",
"customElements": "custom-elements.json",
"exports": {
".": "./index.js",
"./package.json": "./package.json",
"./register.js": "./register.js"
},
"files": [
"**/*.d.ts",
"**/*.js",
"**/*.js.map",
"custom-elements.json"
],
"sideEffects": [
"register.js"
],
"scripts": {
"test": "echo \"Error: run tests from monorepo root.\" && exit 1"
},
"dependencies": {
"@sl-design-system/tooltip": "^1.1.0"
},
"devDependencies": {
"@open-wc/scoped-elements": "^3.0.5"
},
"peerDependencies": {
"@open-wc/scoped-elements": "^3.0.5"
}
}
3 changes: 3 additions & 0 deletions packages/components/ellipsize-text/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { EllipsizeText } from './src/ellipsize-text.js';

customElements.define('sl-ellipsize-text', EllipsizeText);
6 changes: 6 additions & 0 deletions packages/components/ellipsize-text/src/ellipsize-text.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
:host {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
54 changes: 54 additions & 0 deletions packages/components/ellipsize-text/src/ellipsize-text.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, fixture } from '@open-wc/testing';
import { html } from 'lit';
import '../register.js';
import { type EllipsizeText } from './ellipsize-text.js';

describe('sl-ellipsize-text', () => {
let el: EllipsizeText;

beforeEach(async () => {
el = await fixture(html`<sl-ellipsize-text>This is a long text that should be truncated</sl-ellipsize-text>`);
});

it('should render a slot with the text', () => {
const slot = el.renderRoot.querySelector('slot');

expect(slot).to.exist;
expect(
slot
?.assignedNodes()
.map(node => node.textContent?.trim())
.join('')
).to.equal('This is a long text that should be truncated');
});

it('should not have a tooltip by default', () => {
expect(el).not.to.have.attribute('aria-describedby');
});

describe('tooltip', () => {
beforeEach(async () => {
el.style.width = '100px';

// Wait for the resize observer to trigger
await new Promise(resolve => setTimeout(resolve, 100));

// Trigger a focus event to create the tooltip
el.dispatchEvent(new Event('focusin'));
});

it('should have a tooltip when there is not enough space', () => {
const tooltip = el.nextElementSibling;

expect(tooltip).to.exist;
expect(tooltip).to.match('sl-tooltip');
expect(el).to.have.attribute('aria-describedby', tooltip?.id);
});

it('should remove the tooltip when the element is removed from the DOM', () => {
el.remove();

expect(document.querySelector('sl-tooltip')).not.to.exist;
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type Meta, type StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import '../register.js';

type Props = { text: string; width: number };
type Story = StoryObj<Props>;

export default {
title: 'Utilities/Ellipsize Text',
tags: ['draft'],
render: ({ text, width }) => html`<sl-ellipsize-text style="width: ${width}px">${text}</sl-ellipsize-text>`
} satisfies Meta<Props>;

export const Basic: Story = {
args: {
width: 200,
text: 'This is a long text that should be truncated'
}
};
78 changes: 78 additions & 0 deletions packages/components/ellipsize-text/src/ellipsize-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { Tooltip } from '@sl-design-system/tooltip';
import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit';
import styles from './ellipsize-text.scss.js';

declare global {
interface HTMLElementTagNameMap {
'sl-ellipsize-text': EllipsizeText;
}
}

/**
* Small utility component to add ellipsis to text that overflows
* its container. It also adds a tooltip with the full text.
*/
export class EllipsizeText extends ScopedElementsMixin(LitElement) {
/** @internal */
static get scopedElements(): ScopedElementsMap {
return {
'sl-tooltip': Tooltip
};
}

/** @internal */
static override styles: CSSResultGroup = styles;

/** Observe size changes. */
#observer = new ResizeObserver(() => this.#onResize());

/** The lazy tooltip. */
#tooltip?: Tooltip | (() => void);

override connectedCallback(): void {
super.connectedCallback();

this.#observer.observe(this);
}

override disconnectedCallback(): void {
this.#observer.disconnect();

if (this.#tooltip instanceof Tooltip) {
this.#tooltip.remove();
} else if (this.#tooltip) {
this.#tooltip();
}

this.#tooltip = undefined;

super.disconnectedCallback();
}

override render(): TemplateResult {
return html`<slot></slot>`;
}

#onResize(): void {
if (this.offsetWidth < this.scrollWidth) {
this.#tooltip ||= Tooltip.lazy(
jpzwarte marked this conversation as resolved.
Show resolved Hide resolved
this,
tooltip => {
this.#tooltip = tooltip;
tooltip.position = 'bottom';
tooltip.textContent = this.textContent?.trim() || '';
},
{ context: this.shadowRoot! }
);
} else if (this.#tooltip instanceof Tooltip) {
this.removeAttribute('aria-describedby');

this.#tooltip.remove();
this.#tooltip = undefined;
} else if (this.#tooltip) {
this.#tooltip();
this.#tooltip = undefined;
}
}
}
8 changes: 8 additions & 0 deletions packages/components/ellipsize-text/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "./"
},
"include": ["index.ts", "register.ts", "src/**/*.ts"]
}
1 change: 1 addition & 0 deletions packages/components/grid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"dependencies": {
"@sl-design-system/checkbox": "^2.0.0",
"@sl-design-system/ellipsize-text": "^0.0.0",
"@sl-design-system/icon": "^1.0.2",
"@sl-design-system/popover": "^1.1.0",
"@sl-design-system/select": "^1.1.1",
Expand Down
27 changes: 27 additions & 0 deletions packages/components/grid/src/column.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ describe('sl-column', () => {
'data age'
]);
});

it('should not ellipsize the text in the cells', () => {
expect(el.renderRoot.querySelector('sl-ellipsize-text')).not.to.exist;
});

it('should ellipsize the text in the cells when set', async () => {
el.ellipsizeText = true;
await el.updateComplete;

expect(
Array.from(el.renderRoot.querySelectorAll('tbody tr:first-of-type td')).map(
cell => cell.firstElementChild?.tagName === 'SL-ELLIPSIZE-TEXT'
)
).to.deep.equal([true, true, false]);
});

it('should ellipsize the text in the cells when set on the column', async () => {
el.querySelector('sl-grid-column')!.ellipsizeText = true;
el.requestUpdate();
await el.updateComplete;

expect(
Array.from(el.renderRoot.querySelectorAll('tbody tr:first-of-type td')).map(
cell => cell.firstElementChild?.tagName === 'SL-ELLIPSIZE-TEXT'
)
).to.deep.equal([true, false, false]);
});
});

describe('custom renderer', () => {
Expand Down
20 changes: 15 additions & 5 deletions packages/components/grid/src/column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export class GridColumn<T = any> extends LitElement {
/** @internal Emits when the column definition has changed. */
@event({ name: 'sl-column-update' }) columnUpdateEvent!: EventEmitter<SlColumnUpdateEvent<T>>;

/** This will ellipsize the text in the `<td>` elements when it overflows. */
@property({ type: Boolean, attribute: 'ellipsize-text' }) ellipsizeText?: boolean;

/** The parent grid instance. */
@property({ attribute: false })
set grid(value: Grid<T> | undefined) {
Expand Down Expand Up @@ -150,11 +153,18 @@ export class GridColumn<T = any> extends LitElement {
renderData(item: T): TemplateResult {
const parts = ['data', ...this.getParts(item)];

return html`
<td part=${parts.join(' ')}>
${this.renderer ? this.renderer(item) : this.path ? getValueByPath(item, this.path) : 'No path set'}
</td>
`;
let data: unknown;
if (this.renderer) {
data = this.renderer(item);
} else if (this.path) {
data = getValueByPath(item, this.path);
}

if (this.ellipsizeText && typeof data === 'string') {
return html`<td part=${parts.join(' ')}><sl-ellipsize-text>${data}</sl-ellipsize-text></td>`;
} else {
return html`<td part=${parts.join(' ')}>${data || 'No path set'}</td>`;
}
}

renderStyles(): CSSResult | void {}
Expand Down
13 changes: 13 additions & 0 deletions packages/components/grid/src/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { localized } from '@lit/localize';
import { type VirtualizerHostElement, virtualize, virtualizerRef } from '@lit-labs/virtualizer/virtualize.js';
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { EllipsizeText } from '@sl-design-system/ellipsize-text';
import {
ArrayDataSource,
type DataSource,
Expand Down Expand Up @@ -96,6 +97,7 @@ export class Grid<T = any> extends ScopedElementsMixin(LitElement) {
/** @internal */
static get scopedElements(): ScopedElementsMap {
return {
'sl-ellipsize-text': EllipsizeText,
'sl-grid-group-header': GridGroupHeader
};
}
Expand Down Expand Up @@ -191,6 +193,9 @@ export class Grid<T = any> extends ScopedElementsMixin(LitElement) {
/** @internal Provides clarity when 'between-or-on-top' is the active draggableRows value. */
@property({ reflect: true, attribute: 'drop-target-mode' }) dropTargetMode?: 'between' | 'on-grid' | 'on-top';

/** This will ellipsize the text in the `<td>` elements if it overflows. */
@property({ type: Boolean, reflect: true, attribute: 'ellipsize-text' }) ellipsizeText?: boolean;

/** Custom renderer for group headers. */
@property({ attribute: false }) groupHeaderRenderer?: GridGroupHeaderRenderer;

Expand Down Expand Up @@ -279,6 +284,10 @@ export class Grid<T = any> extends ScopedElementsMixin(LitElement) {
if (changes.has('scopedElements')) {
this.#addScopedElements(this.scopedElements);
}

if (changes.has('ellipsizeText')) {
this.view.headerRows.at(-1)?.forEach(col => (col.ellipsizeText = this.ellipsizeText));
}
}

override render(): TemplateResult {
Expand Down Expand Up @@ -692,6 +701,10 @@ export class Grid<T = any> extends ScopedElementsMixin(LitElement) {
col.itemsChanged();
}

if (this.ellipsizeText) {
col.ellipsizeText = this.ellipsizeText;
}

if (col instanceof GridFilterColumn) {
const { value } = this.dataSource?.filters.get(col.id) || {};
if (value) {
Expand Down
Loading