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

feat(combobox): display overflowing selected content in a tooltip #9014

40 changes: 20 additions & 20 deletions packages/calcite-components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ export namespace Components {
*/
"heading": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -1311,7 +1311,7 @@ export namespace Components {
*/
"activeRange": "start" | "end";
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -1771,7 +1771,7 @@ export namespace Components {
*/
"heading": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -2144,7 +2144,7 @@ export namespace Components {
*/
"form": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -3486,7 +3486,7 @@ export namespace Components {
*/
"heading": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -3556,7 +3556,7 @@ export namespace Components {
*/
"getSelectedItems": () => Promise<Map<string, HTMLCalcitePickListItemElement>>;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -3586,7 +3586,7 @@ export namespace Components {
*/
"groupTitle": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
}
Expand Down Expand Up @@ -3680,7 +3680,7 @@ export namespace Components {
*/
"heading": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -5176,7 +5176,7 @@ export namespace Components {
*/
"heading": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -5204,7 +5204,7 @@ export namespace Components {
*/
"closed": boolean;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel": HeadingLevel;
/**
Expand Down Expand Up @@ -7974,7 +7974,7 @@ declare namespace LocalJSX {
*/
"heading": string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -8743,7 +8743,7 @@ declare namespace LocalJSX {
*/
"activeRange"?: "start" | "end";
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -9225,7 +9225,7 @@ declare namespace LocalJSX {
*/
"heading"?: string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -9617,7 +9617,7 @@ declare namespace LocalJSX {
*/
"form"?: string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -11045,7 +11045,7 @@ declare namespace LocalJSX {
*/
"heading"?: string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -11112,7 +11112,7 @@ declare namespace LocalJSX {
*/
"filteredItems"?: HTMLCalcitePickListItemElement[];
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -11145,7 +11145,7 @@ declare namespace LocalJSX {
*/
"groupTitle"?: string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
}
Expand Down Expand Up @@ -11254,7 +11254,7 @@ declare namespace LocalJSX {
*/
"heading"?: string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -12788,7 +12788,7 @@ declare namespace LocalJSX {
*/
"heading"?: string;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down Expand Up @@ -12820,7 +12820,7 @@ declare namespace LocalJSX {
*/
"closed"?: boolean;
/**
* Specifies the number at which section headings should start.
* Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling.
*/
"headingLevel"?: HeadingLevel;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { html } from "../../../support/formatting";
import { CSS as ComboboxItemCSS } from "../combobox-item/resources";
import { CSS as XButtonCSS } from "../functional/XButton";
import { CSS } from "./resources";
import { getElementXY, skipAnimations } from "../../tests/utils";

const selectionModes = ["single", "single-persist", "ancestors", "multiple"];
Expand Down Expand Up @@ -2003,4 +2004,39 @@ describe("calcite-combobox", () => {
await combobox.press("Enter");
expect(chips.length).toBe(2);
});

it("shows tooltip for combobox with truncated long single-select values", async () => {
const longValue = "Natural Resources Including a Comprehensive List of Resources to Protect or Plunder";
const longValue2 = "Includes activities such as tree planting, timber harvesting, and wildlife management";
const page = await newE2EPage();
await page.setContent(html`
<div style="width:200px;">
<calcite-combobox placeholder="Select a field" selection-mode="single">
<calcite-combobox-item selected value="NaturalResources" text-label="${longValue}"></calcite-combobox-item>
<calcite-combobox-item value="Agriculture" text-label="Agriculture"></calcite-combobox-item>
<calcite-combobox-item value="Forestry" text-label="${longValue2}"></calcite-combobox-item>
<calcite-combobox-item value="Mining" text-label="Mining"></calcite-combobox-item>
<calcite-combobox-item value="Business" text-label="Business"></calcite-combobox-item>
</calcite-combobox>
</div>
`);

await page.waitForChanges();

const inputWrap = await page.find(`calcite-combobox >>> .${CSS.inputWrap}`);
expect(inputWrap.title).toEqual(longValue);

const combobox = await page.find("calcite-combobox");
await combobox.click();

await (await combobox.find("calcite-combobox-item[value=Agriculture]")).click();
const inputWrap1 = await page.find(`calcite-combobox >>> .${CSS.inputWrap}`);
expect(inputWrap1.title).not.toEqual(longValue);

await combobox.click();

await (await combobox.find("calcite-combobox-item[value=Forestry]")).click();
const inputWrap2 = await page.find(`calcite-combobox >>> .${CSS.inputWrap}`);
expect(inputWrap2.title).toEqual(longValue2);
});
});
44 changes: 41 additions & 3 deletions packages/calcite-components/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ export class Combobox
disconnectedCallback(): void {
this.mutationObserver?.disconnect();
this.resizeObserver?.disconnect();
this.textLabelElResizeObserver?.disconnect();
disconnectInteractive(this);
disconnectLabel(this);
disconnectForm(this);
Expand Down Expand Up @@ -531,6 +532,9 @@ export class Combobox

@State() text = "";

/** keeps track of the tooltipText */
@State() tooltipText: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this should just change to @State() showTooltipText = false;

No need to store the string value since its just the selected item's display value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Storing the value is duplicative. You should be able to get the selected item's value with the element reference in the ResizeObserver callback function.


/** when search text is cleared, reset active to */
@Watch("text")
textHandler(): void {
Expand All @@ -557,6 +561,9 @@ export class Combobox
this.refreshSelectionDisplay();
});

/** keep track of the rendered textLabelEl */
private textLabelEl: HTMLSpanElement;

private guid = guid();

private inputHeight = 0;
Expand Down Expand Up @@ -764,6 +771,31 @@ export class Combobox
this.el.removeEventListener("calciteComboboxOpen", this.toggleOpenEnd);
};

private setTooltipText = (): void => {
const { textLabelEl } = this;
if (!textLabelEl) {
return;
}

requestAnimationFrame(() => {
this.tooltipText =
textLabelEl.offsetWidth < textLabelEl.scrollWidth ? textLabelEl.innerText : null;
Copy link
Contributor

@eriklharper eriklharper Apr 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would compare the actual text width using this util with the computed width of the text's container. Also, rather than making a separate @State property for the tooltip text, just use the selected item's display value (which is verbatim what the tooltip would be) and in the render method, just conditionally render the title attribute based on if the value is truncated or not.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that util makes sense in this scenario. We just want to know if the offsetWidth is less than the scrollWidth which is simpler than creating a canvas and measuring.

I do agree that the @State()isn't ideal. It could probably just be a boolean for whether to render the title or not and use the selected items display value. I think it still needs a state because the render method wouldn't update when the resize observer fires unless it tells some state to change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Button seems to do the same logic so a util that handles this might be useful. Something like hasScrollbar(el);

private setTooltipText = (): void => {
const { contentEl } = this;
if (contentEl) {
this.tooltipText =
contentEl.offsetWidth < contentEl.scrollWidth ? this.el.innerText || null : null;
}
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also thought, why not just always render the full title attribute? Is the intention to not unnecessarily crowd the UI when hovering the selected item with the mouse if the full text is visible? Either way, rendering the title all the time would just be the simplest approach, no need to calculate overflow whatsoever... Just some food for thought.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought that too. IMO that would be the simplest option. It is a tiny bit odd to conditionally render a title attribute.

Copy link
Contributor Author

@Elijbet Elijbet Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This came up with tab-title/dropdown truncation as well. Since truncation is happening on all long text items, we decided at that time that there was no need to have a popup with duplicate info where not necessary. The question was, what would a dropdown item look like with a title tooltip with a repeat text and calcite-tooltip?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Elijbet for the refresher! If we play this out to possible truncation behavior in places like List or Tree, I agree that keeping this conditional is a cleaner UX for the end user. If the text is short enough to be displayed then there isn't much reason to also render the title attribute. Add a possible calcite-tooltip on top of that and there's suddenly a lot going on. Reducing redundancy feels like a win.

Copy link
Contributor

@ashetland ashetland Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've also been talking about mapping out a system-wide configurable text-overflow. With that in mind and since, in this case, the entire text can be read by opening the dropdown, I want to put it out there that we could abandon using the title attribute here for the time being.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that makes sense to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing the issue then.

});
};

private textLabelElResizeObserver = createObserver("resize", this.setTooltipText);

private setTextLabelEl = (el: HTMLSpanElement): void => {
this.textLabelEl = el;
this.setTooltipText();
};

private setTextInputWrapEl = (el: HTMLInputElement): void => {
this.textInput = el;
this.textLabelElResizeObserver?.observe(el);
this.setTooltipText();
};

onBeforeOpen(): void {
this.scrollToActiveItem();
this.calciteComboboxBeforeOpen.emit();
Expand Down Expand Up @@ -1527,17 +1559,20 @@ export class Combobox
}

private renderInput(): VNode {
const { guid, disabled, placeholder, selectionMode, selectedItems, open } = this;
const { guid, disabled, placeholder, selectionMode, selectedItems, open, tooltipText } = this;
const single = isSingleLike(selectionMode);
const selectedItem = selectedItems[0];
const showLabel = !open && single && !!selectedItem;

return (
<span
class={{
"input-wrap": true,
"input-wrap--single": single,
[CSS.inputWrap]: true,
[CSS.inputWrapSingle]: single,
}}
title={tooltipText}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={this.setTextInputWrapEl}
>
{showLabel && (
<span
Expand All @@ -1546,6 +1581,8 @@ export class Combobox
"label--icon": !!selectedItem?.icon,
}}
key="label"
// eslint-disable-next-line react/jsx-sort-props -- auto-generated by @esri/calcite-components/enforce-ref-last-prop
ref={this.setTextLabelEl}
>
{selectedItem.textLabel}
</span>
Expand Down Expand Up @@ -1688,6 +1725,7 @@ export class Combobox
[CSS.selectionDisplaySingle]: singleSelectionDisplay,
}}
key="grid"
// eslint-disable-next-line react/jsx-sort-props -- auto-generated by @esri/calcite-components/enforce-ref-last-prop
ref={this.setChipContainerEl}
>
{!singleSelectionMode && !singleSelectionDisplay && this.renderChips()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const ComboboxChildSelector = `${ComboboxItem}, ${ComboboxItemGroup}`;

export const CSS = {
chipInvisible: "chip--invisible",
inputWrap: "input-wrap",
driskull marked this conversation as resolved.
Show resolved Hide resolved
inputWrapSingle: "input-wrap--single",
selectionDisplayFit: "selection-display-fit",
selectionDisplaySingle: "selection-display-single",
listContainer: "list-container",
Expand Down
Loading