Skip to content

Commit ff48dc2

Browse files
dchyunshleewhite
authored and
shleewhite
committedMar 10, 2025
Tag - Text truncation for overflow fix (#2655)
1 parent 58c0a28 commit ff48dc2

File tree

8 files changed

+217
-21
lines changed

8 files changed

+217
-21
lines changed
 

‎.changeset/brown-wolves-admire.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hashicorp/design-system-components": minor
3+
---
4+
5+
`Tag` - Truncate any text that is longer than about 20 characters, and add a tooltip with the full text when truncation occurs
6+
7+
`Tag` - Added `@tooltipPlacement` argument

‎packages/components/src/components/hds/tag/index.hbs

+56-17
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,68 @@
22
Copyright (c) HashiCorp, Inc.
33
SPDX-License-Identifier: MPL-2.0
44
}}
5-
<Hds::Text::Body class={{this.classNames}} @tag="span" @size="100" @weight="medium" @color="primary" ...attributes>
5+
<Hds::Text::Body
6+
class={{this.classNames}}
7+
@tag="span"
8+
@size="100"
9+
@weight="medium"
10+
@color="primary"
11+
{{this._setUpObserver}}
12+
...attributes
13+
>
614
{{#if this.onDismiss}}
715
<button class="hds-tag__dismiss" type="button" aria-label={{this.ariaLabel}} {{on "click" this.onDismiss}}>
816
<Hds::Icon class="hds-tag__dismiss-icon" @name="x" @size="16" />
917
</button>
1018
{{/if}}
1119
{{#if (or @href @route)}}
12-
<Hds::Interactive
13-
class="hds-tag__link"
14-
@current-when={{@current-when}}
15-
@models={{hds-link-to-models @model @models}}
16-
@query={{hds-link-to-query @query}}
17-
@replace={{@replace}}
18-
@route={{@route}}
19-
@isRouteExternal={{@isRouteExternal}}
20-
@href={{@href}}
21-
@isHrefExternal={{@isHrefExternal}}
22-
>
23-
{{this.text}}
24-
</Hds::Interactive>
20+
{{#if this._isTextOverflow}}
21+
<Hds::Interactive
22+
class="hds-tag__link"
23+
@current-when={{@current-when}}
24+
@models={{hds-link-to-models @model @models}}
25+
@query={{hds-link-to-query @query}}
26+
@replace={{@replace}}
27+
@route={{@route}}
28+
@isRouteExternal={{@isRouteExternal}}
29+
@href={{@href}}
30+
@isHrefExternal={{@isHrefExternal}}
31+
{{hds-tooltip this.text options=(hash placement=this.tooltipPlacement)}}
32+
>
33+
<div class="hds-tag__text-container">
34+
{{this.text}}
35+
</div>
36+
</Hds::Interactive>
37+
{{else}}
38+
<Hds::Interactive
39+
class="hds-tag__link"
40+
@current-when={{@current-when}}
41+
@models={{hds-link-to-models @model @models}}
42+
@query={{hds-link-to-query @query}}
43+
@replace={{@replace}}
44+
@route={{@route}}
45+
@isRouteExternal={{@isRouteExternal}}
46+
@href={{@href}}
47+
@isHrefExternal={{@isHrefExternal}}
48+
>
49+
<div class="hds-tag__text-container">
50+
{{this.text}}
51+
</div>
52+
</Hds::Interactive>
53+
{{/if}}
2554
{{else}}
26-
<span class="hds-tag__text">
27-
{{this.text}}
28-
</span>
55+
{{#if this._isTextOverflow}}
56+
<Hds::TooltipButton class="hds-tag__text" @text={{this.text}} @placement={{this.tooltipPlacement}}>
57+
<div class="hds-tag__text-container">
58+
{{this.text}}
59+
</div>
60+
</Hds::TooltipButton>
61+
{{else}}
62+
<span class="hds-tag__text">
63+
<div class="hds-tag__text-container">
64+
{{this.text}}
65+
</div>
66+
</span>
67+
{{/if}}
2968
{{/if}}
3069
</Hds::Text::Body>

‎packages/components/src/components/hds/tag/index.ts

+50
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,73 @@
44
*/
55

66
import Component from '@glimmer/component';
7+
import { tracked } from '@glimmer/tracking';
78
import { assert } from '@ember/debug';
9+
import { modifier } from 'ember-modifier';
810

911
import { HdsTagColorValues } from './types.ts';
1012
import type { HdsTagColors } from './types.ts';
13+
import { HdsTagTooltipPlacementValues } from './types.ts';
14+
import type { HdsTagTooltipPlacements } from './types.ts';
1115
import type { HdsInteractiveSignature } from '../interactive/';
1216

1317
export const COLORS: string[] = Object.values(HdsTagColorValues);
1418
export const DEFAULT_COLOR = HdsTagColorValues.Primary;
19+
export const TOOLTIP_PLACEMENTS: string[] = Object.values(
20+
HdsTagTooltipPlacementValues
21+
);
22+
export const DEFAULT_TOOLTIP_PLACEMENT = HdsTagTooltipPlacementValues.Top;
1523

1624
export interface HdsTagSignature {
1725
Args: HdsInteractiveSignature['Args'] & {
1826
color?: HdsTagColors;
1927
text: string;
2028
ariaLabel?: string;
29+
tooltipPlacement: HdsTagTooltipPlacements;
2130
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2231
onDismiss?: (event: MouseEvent, ...args: any[]) => void;
2332
};
2433
Element: HTMLSpanElement;
2534
}
2635

2736
export default class HdsTag extends Component<HdsTagSignature> {
37+
@tracked private _isTextOverflow!: boolean;
38+
private _observer!: ResizeObserver;
39+
40+
private _setUpObserver = modifier((element: HTMLElement) => {
41+
// Used to detect when text is clipped to one line, and tooltip should be added
42+
this._observer = new ResizeObserver((entries) => {
43+
entries.forEach((entry) => {
44+
this._isTextOverflow = this._isOverflow(
45+
entry.target.querySelector('.hds-tag__text-container')!
46+
);
47+
});
48+
});
49+
this._observer.observe(element);
50+
51+
return () => {
52+
this._observer.disconnect();
53+
};
54+
});
55+
56+
/**
57+
* @param tooltioPlacement
58+
* @type {string}
59+
* @default top
60+
* @description The placement property of the tooltip attached to the tag text.
61+
*/
62+
get tooltipPlacement(): HdsTagTooltipPlacements {
63+
const { tooltipPlacement = DEFAULT_TOOLTIP_PLACEMENT } = this.args;
64+
65+
assert(
66+
'@tooltipPlacement for "Hds::Tag" must have a valid value',
67+
tooltipPlacement == undefined ||
68+
TOOLTIP_PLACEMENTS.includes(tooltipPlacement)
69+
);
70+
71+
return tooltipPlacement;
72+
}
73+
2874
/**
2975
* @param onDismiss
3076
* @type {function}
@@ -104,4 +150,8 @@ export default class HdsTag extends Component<HdsTagSignature> {
104150

105151
return classes.join(' ');
106152
}
153+
154+
private _isOverflow(el: Element): boolean {
155+
return el.scrollHeight > el.clientHeight;
156+
}
107157
}

‎packages/components/src/components/hds/tag/types.ts

+17
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,20 @@ export enum HdsTagColorValues {
88
Secondary = 'secondary',
99
}
1010
export type HdsTagColors = `${HdsTagColorValues}`;
11+
12+
export enum HdsTagTooltipPlacementValues {
13+
Top = 'top',
14+
TopStart = 'top-start',
15+
TopEnd = 'top-end',
16+
Right = 'right',
17+
RightStart = 'right-start',
18+
RightEnd = 'right-end',
19+
Bottom = 'bottom',
20+
BottomStart = 'bottom-start',
21+
BottomEnd = 'bottom-end',
22+
Left = 'left',
23+
LeftStart = 'left-start',
24+
LeftEnd = 'left-end',
25+
}
26+
27+
export type HdsTagTooltipPlacements = `${HdsTagTooltipPlacementValues}`;

‎packages/components/src/styles/components/tag.scss

+28
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ $hds-tag-border-radius: 50px;
1515
.hds-tag {
1616
display: inline-flex;
1717
align-items: stretch;
18+
width: fit-content;
19+
max-width: 100%;
1820
line-height: 1rem; // 16px - override `body-100`
1921
vertical-align: middle;
2022
background-color: var(--token-color-surface-interactive);
@@ -41,12 +43,23 @@ $hds-tag-border-radius: 50px;
4143
.hds-tag__text,
4244
.hds-tag__link {
4345
flex: 1 0 0;
46+
max-width: 166px; // account for excess horizontal padding of text in non-dismissible variant
4447
padding: 3px 10px 5px 10px;
4548
border-radius: inherit;
4649
}
4750

51+
.hds-tag__text-container {
52+
display: -webkit-box;
53+
overflow: hidden;
54+
word-break: break-all;
55+
-webkit-box-orient: vertical;
56+
-webkit-line-clamp: 1;
57+
line-clamp: 1;
58+
}
59+
4860
.hds-tag__dismiss ~ .hds-tag__text,
4961
.hds-tag__dismiss ~ .hds-tag__link {
62+
max-width: 160px;
5063
padding: 3px 8px 5px 6px;
5164
border-top-left-radius: 0;
5265
border-bottom-left-radius: 0;
@@ -76,6 +89,21 @@ $hds-tag-border-radius: 50px;
7689
}
7790
}
7891

92+
.hds-tooltip-button.hds-tag__text {
93+
cursor: text;
94+
user-select: text;
95+
96+
&:focus,
97+
&.mock-focus {
98+
@include hds-focus-ring-basic();
99+
z-index: 1; // ensures focus is not obscured by adjacent elements
100+
}
101+
102+
&:focus-visible::before {
103+
box-shadow: none; // override default tooltip button focus styles
104+
}
105+
}
106+
79107
// COLORS (FOR LINK)
80108

81109
.hds-tag--color-primary {

‎showcase/app/routes/components/tag.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
import Route from '@ember/routing/route';
77
import { COLORS } from '@hashicorp/design-system-components/components/hds/tag';
8+
import { TOOLTIP_PLACEMENTS } from '@hashicorp/design-system-components/components/hds/tag';
89

910
export default class ComponentsTagRoute extends Route {
1011
model() {
1112
// these are used only for presentation purpose in the showcase
1213
const STATES = ['default', 'hover', 'active', 'focus'];
13-
return { COLORS, STATES };
14+
return { COLORS, TOOLTIP_PLACEMENTS, STATES };
1415
}
1516
}

‎showcase/app/templates/components/tag.hbs

+27-2
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,22 @@
3333
</Shw::Flex>
3434

3535
<Shw::Flex @label="With long text" as |SF|>
36-
<SF.Item {{style width="200px"}}>
36+
<SF.Item>
3737
<Hds::Tag @text="This is a very long text that should go on multiple lines" @onDismiss={{this.noop}} />
3838
</SF.Item>
39-
<SF.Item {{style width="200px"}}>
39+
<SF.Item>
4040
<Hds::Tag @text="This is a very long text that should go on multiple lines" />
4141
</SF.Item>
42+
<SF.Item>
43+
<Hds::Tag
44+
@text="This is a very long text that should go on multiple lines"
45+
@onDismiss={{this.noop}}
46+
@route="components.tag"
47+
/>
48+
</SF.Item>
49+
<SF.Item>
50+
<Hds::Tag @text="This is a very long text that should go on multiple lines" @route="components.tag" />
51+
</SF.Item>
4252
</Shw::Flex>
4353

4454
<Shw::Divider @level={{2}} />
@@ -119,4 +129,19 @@
119129
{{/let}}
120130
</Shw::Grid>
121131

132+
<Shw::Divider @level={{2}} />
133+
134+
<Shw::Text::H2>Tooltip Placements</Shw::Text::H2>
135+
136+
<Shw::Grid @columns={{3}} as |SG|>
137+
{{#each @model.TOOLTIP_PLACEMENTS as |place|}}
138+
<SG.Item>
139+
<Hds::Tag
140+
@text="{{place}} This is a very long text that should go on multiple lines"
141+
@tooltipPlacement={{place}}
142+
/>
143+
</SG.Item>
144+
{{/each}}
145+
</Shw::Grid>
146+
122147
</section>

‎showcase/tests/integration/components/hds/tag/index-test.js

+30-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55

66
import { module, test } from 'qunit';
77
import { setupRenderingTest } from 'ember-qunit';
8-
import { render, resetOnerror, setupOnerror } from '@ember/test-helpers';
8+
import {
9+
render,
10+
resetOnerror,
11+
setupOnerror,
12+
waitFor,
13+
} from '@ember/test-helpers';
914
import { hbs } from 'ember-cli-htmlbars';
1015

1116
module('Integration | Component | hds/tag/index', function (hooks) {
@@ -25,6 +30,7 @@ module('Integration | Component | hds/tag/index', function (hooks) {
2530
await render(hbs`<Hds::Tag @text="My tag" />`);
2631
assert.dom('button.hds-tag__dismiss').doesNotExist();
2732
});
33+
2834
test('it should render the "dismiss" button if a callback function is passed to the @onDismiss argument', async function (assert) {
2935
this.set('NOOP', () => {});
3036
await render(hbs`<Hds::Tag @text="My tag" @onDismiss={{this.NOOP}} />`);
@@ -44,6 +50,7 @@ module('Integration | Component | hds/tag/index', function (hooks) {
4450
.dom('button.hds-tag__dismiss')
4551
.hasAttribute('aria-label', 'Please dismiss My tag');
4652
});
53+
4754
// COLOR
4855

4956
test('it should render the primary color as the default if no @color prop is declared when the text is a link', async function (assert) {
@@ -52,12 +59,14 @@ module('Integration | Component | hds/tag/index', function (hooks) {
5259
);
5360
assert.dom('#test-link-tag').hasClass('hds-tag--color-primary');
5461
});
62+
5563
test('it should render the correct CSS color class if the @color prop is declared when the text is a link', async function (assert) {
5664
await render(
5765
hbs`<Hds::Tag @text="My text tag" @href="/" @color="secondary" id="test-link-tag"/>`
5866
);
5967
assert.dom('#test-link-tag').hasClass('hds-tag--color-secondary');
6068
});
69+
6170
test('it should throw an assertion if an incorrect value for @color is provided when the text is a link', async function (assert) {
6271
const errorMessage =
6372
'@color for "Hds::Tag" must be one of the following: primary, secondary; received: foo';
@@ -70,6 +79,7 @@ module('Integration | Component | hds/tag/index', function (hooks) {
7079
throw new Error(errorMessage);
7180
});
7281
});
82+
7383
test('it should throw an assertion if @color is provided without @href or @route', async function (assert) {
7484
const errorMessage =
7585
'@color can only be applied to "Hds::Tag" along with either @href or @route';
@@ -82,4 +92,23 @@ module('Integration | Component | hds/tag/index', function (hooks) {
8292
throw new Error(errorMessage);
8393
});
8494
});
95+
96+
// OVERFLOW
97+
98+
test('it should not render a tooltip if the text does not overflow', async function (assert) {
99+
await render(hbs`
100+
<Hds::Tag @text="My text tag" id="test-tag"/>
101+
`);
102+
assert.dom('.hds-tooltip-button').doesNotExist();
103+
});
104+
105+
test('it should render a tooltip if the text overflows', async function (assert) {
106+
await render(hbs`
107+
<div style="width: 50px;">
108+
<Hds::Tag @text="This is a very long text that should go on multiple lines" id="test-tag"/>
109+
</div>
110+
`);
111+
await waitFor('.hds-tooltip-button', { timeout: 1000 });
112+
assert.dom('.hds-tooltip-button').exists();
113+
});
85114
});

0 commit comments

Comments
 (0)