minor
#29497
React UI tooltips: clipped table cells and column headers, compact toolbar buttons and attribute descriptions get tooltips; native title attributes replaced by bridge tooltips
Reported from the Consulting application on 8.0.0-alpha7 (react view UI, Issue #94), then widened to the tooltip behavior of the React UI as a whole.
Scope
In the React UI (.view.xml layer, tl-react-bridge, the React controls) a text the user cannot read where it belongs is not offered as a tooltip, and the descriptions of model elements (#29484) are not shown where their labels are. After this ticket:
- A table cell whose value is cut off at the column edge shows the full value as a tooltip on hover; a cell whose value fits shows nothing. The same for a column heading whose label is cut off.
- A toolbar that collapsed its buttons to their icons (compact mode) shows a button's label as its tooltip.
- The description of a model attribute is the tooltip of its form-field label (in view and in edit mode; the click-to-open help icon stays, see #29491) and of its column heading in a table.
- Every tooltip of the React controls looks and behaves the same: the buttons of panels, windows, the sidebar, value lists, form groups, the WYSIWYG toolbar and every other icon-only control use the bridge's tooltip instead of the browser's native title tooltip (or a private tooltip library), which has its own delay and look and does nothing on touch.
- No table hover talks to the server any more: table tooltips are decided on the client, from the text that is already in the DOM.
Findings
- Table cells (the original report below): the table asks the server for a tooltip on every hover (data-tooltip="dynamic" → /react-api/tooltip), and TableViewControl#getTooltipContent answers only CellContent.Labeled; a column over a model attribute renders CellContent.Raw, so the answer is always null. The full value is in the DOM already — only the CSS clips it (.tlTableView__cell > span { text-overflow: ellipsis }), so no server interaction is needed for the clipped case.
- Icon-only buttons: TLButton uses its label as tooltip only when its own displayMode is icon-only. The toolbar's compact mode hides the labels by CSS alone (.tlToolbar--compact … .tlReactButton__label { display: none }), so a collapsed button has no tooltip.
- Attribute descriptions: AttributeFieldControl reads the attribute's description (TLModelI18N key, .tooltip) and passes it as help text to the field chrome, shown by the "?" click toggle; the label itself has no tooltip. The label's own tooltip slot (ReactFormFieldChromeControl#setTooltip) is used only by the view designer. A table column heading carries no tooltip at all. So no description is a tooltip anywhere in the React UI, although #29484 names forms and table headings as the places where they appear.
- Native title attributes in about 70 places of the React controls, a private Radix tooltip in the WYSIWYG toolbar, and further icon-only controls (color and icon swatches, selection clear, help icon, drawer toggle, tree chevrons) with an accessible name but no tooltip at all.
- Stranded tooltips: the tooltip host cancelled the pending close as soon as the pointer entered any element under a tooltip declaration, and a resolution that came back empty (the table's null answer) returned silently — a tooltip opened on a toolbar button stayed on screen indefinitely once the pointer jumped into a table cell.
Solution
One principle: a text the user cannot read where it belongs is offered as a tooltip, decided on the client; a description is offered where its label is.
- Bridge (tl-react-bridge, tooltip-host.ts): a tooltip mode data-tooltip="content" whose tooltip is the element's own text, and a condition data-tooltip-when="truncated", read on the data-tooltip element or on any element between it and the hovered one: the tooltip is shown only while that element's text is not fully readable — the element or a descendant is clipped (scrollWidth > clientWidth) or a text-bearing descendant is hidden. A declaration whose condition is not met yields nothing (no fall-through to an enclosing declaration). Both compose with the existing modes (text:, html:, key:, dynamic). Constants TOOLTIP_ATTR, TOOLTIP_WHEN_ATTR, WHEN_TRUNCATED, the helper tooltipProps(text) and the pair TOOLTIP_WHEN_CLIPPED are exported to the controls. A hover whose resolution yields nothing — or arrives empty from the server — now closes the open tooltip like a tooltip-less element does.
- Table: every body cell carries content + truncated (in the tree column on the value wrapper .tlTableView__treeValue, so the expand glyph and the group size stay out of the text); a cell with a distinct tooltip carries data-tooltip="text:…" instead. The heading label carries the column description when there is one, else its own text under the truncated condition. CellContent gets default String tooltip() (Labeled through its record component, Raw gains a tooltip component with the single-argument constructor kept); TableViewControl sends the distinct tooltips per row under tooltips and the description of each column's label key (ResKey#tooltipOptional()) under the column's tooltip. Its TooltipProvider implementation and the dynamic resolution are removed; the table's own 12 native titles are bridge tooltips. Test: TestTableTooltips.
- Buttons: an explicit tooltip wins; in icon-only mode the label is the tooltip; in every other mode the label is the tooltip under the truncated condition, so a compact toolbar — or any stylesheet that hides the label — yields it.
- Descriptions: ReactFormFieldChromeControl gets a plain-text label tooltip (setTooltipText, state tooltipText, no round trip; a rich setTooltip still wins) that AttributeFieldControl feeds with the attribute's description in every mode; the help icon stays. Test: TestFormFieldTooltips.
- Sweep: all native title attributes of the React controls (except iframe titles) are bridge tooltips, with aria-label added where the title was the only accessible name; icon-only controls that had only an aria-label offer it as tooltip too; the WYSIWYG toolbar's Radix tooltip and its dependency are removed in favour of the bridge; the hard-coded English Help/`Expand`/`Collapse` labels of the help icon and the tree chevrons are i18n keys (js.formField.help, js.treeView.expand, js.treeView.collapse).
Original report
What happens
The table asks for a cell tooltip on every hover and the server can never answer, so no table cell in an application built on <table> with model attribute columns can ever have a tooltip.
The client side is complete. TableViewControl renders its container with data-tooltip="dynamic", and tl-react-bridge resolves the hovered element up the ancestor chain, fires tl-tooltip-resolve, and the table's own handler answers with row_<index>|<column>. That key goes to /react-api/tooltip.
The server side then drops it:
// TableViewControl#getTooltipContent
CellContent content = _view.cell(single.get(0), key.substring(separator + 1));
if (content instanceof CellContent.Labeled labeled
&& labeled.tooltip() != null && !labeled.tooltip().isEmpty()) {
return new TooltipContent(labeled.tooltip(), null);
}
return null;
Only CellContent.Labeled is considered. But a column bound to a model attribute never produces one — ColumnProviderService#displayContent returns CellContent.Raw wrapping the attribute's read-only field control:
public static CellContent displayContent(TLStructuredTypePart part, Object value) {
if (part == null) {
return CellContent.text(label(value));
}
return new CellContent.Raw((CellControlFactory) context -> FieldControlService.getInstance()
.createDisplayControl(context, part, value));
}
RowSetTableControl does the same for both its editable and its read-only cells. So for the ordinary case — a declarative <table> over business objects — the answer is constantly null, and every hover pays for a round trip that cannot produce anything.
This matters most where the text is cut off. Cells are one line; a long value ends in an ellipsis and the rest is only reachable by opening the row. In our client table the //Address// column holds a contact person, a street, a city and a phone number, of which about half is visible.
Suggested fix
Three small, additive steps:
- CellContent gets default String tooltip() { return null; }. Labeled already satisfies it through its record component of the same name; Raw gains a tooltip component, with the existing single-argument form kept.
- TableViewControl#getTooltipContent reads content.tooltip() instead of testing for Labeled — the method gets shorter, and every CellContent variant becomes able to carry a tooltip.
- ColumnProviderService#displayContent (and the corresponding place in RowSetTableControl) passes label(value) as that tooltip. The dynamic path then works for every model attribute column in every application, with no per-application configuration.
Separate wish: only show it when the text is actually cut off
With the above, a cell whose text fits would show a tooltip repeating what is already on screen. What is missing for that is a way to make the tooltip conditional on the anchor overflowing — an opt-in attribute read in the same place data-tooltip is resolved, e.g.:
data-tooltip-when="overflow" // show only while anchor.scrollWidth > anchor.clientWidth
That is the one piece an application cannot express today, and it is what makes cell tooltips bearable rather than noisy. It would serve column headers as well, which truncate the same way.
Workaround in use
A client-side script (consulting-cell-tooltip.js, ~40 lines) measures the hovered cell and sets data-tooltip="text:<full value>" on it when it overflows, removing it again when it does not. It uses the framework's own text: mode, so the popover, its placement, role="tooltip", Escape and outside-press all come from tl-react-bridge — nothing is reimplemented.
The listener has to sit on window in the capture phase: the framework registers document.addEventListener("pointerover", ..., true), and capture descends from the window, so that is the only position guaranteed to have set the attribute before the framework reads it. The cell wins over the container's dynamic because resolution walks outwards from the hovered element.
Verified in the browser: of twelve cells the two truncated addresses get the popover with the full value, the ten that fit get none. The file is deleted once this ticket ships.
Related
- #29491 — same area, the form field's help text should be an anchored overlay.
- #29487 — FormElement.Config has no max-columns / label-position.
- #29475 — no configurable column width on <table>.
- #29484 — model element descriptions as tooltips; that ticket supplies texts, this one the plumbing for cells.