fix(web-shell): avoid duplicate inline tag tooltips (#6729)

* fix(web-shell): avoid duplicate inline tag tooltips

* fix(web-shell): preserve inline tooltip fallback

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
dreamWB 2026-07-12 08:57:57 +08:00 committed by GitHub
parent 01d406f1cc
commit 8a2be47ab7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 83 additions and 10 deletions

View file

@ -122,6 +122,72 @@ describe('useComposerCore inline tags', () => {
warn.mockRestore();
});
it('uses a custom inline tooltip without a native title', async () => {
await mount({
composerInput: {
tags: [{ id: 'orders', label: 'Table', value: 'orders' }],
tagPlacement: 'inline',
},
renderComposerTagTooltip: () => 'Details',
});
const tooltip = document.body.querySelector('[role="tooltip"]');
expect(tooltip?.textContent).toBe('Details');
expect(tooltip?.parentElement?.getAttribute('title')).toBeNull();
expect(tooltip?.id).toBeTruthy();
expect(tooltip?.parentElement?.getAttribute('aria-describedby')).toBe(
tooltip?.id,
);
});
it('falls back to a native title when attaching an inline tooltip fails', async () => {
const error = new Error('append failed');
const appendChild = HTMLElement.prototype.appendChild;
let failingTooltip: HTMLElement | null = null;
let readFailingChipTitle: (() => string) | null = null;
let dispatchOnFailingChip: ((event: Event) => boolean) | null = null;
const appendChildSpy = vi
.spyOn(HTMLElement.prototype, 'appendChild')
.mockImplementation(function (child) {
if (
child instanceof HTMLElement &&
child.getAttribute('role') === 'tooltip'
) {
failingTooltip = child;
readFailingChipTitle = () => this.title;
dispatchOnFailingChip = (event) => this.dispatchEvent(event);
throw error;
}
return appendChild.call(this, child);
});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {
expect(readFailingChipTitle?.()).toBe('Details');
});
try {
await mount({
composerInput: {
tags: [{ id: 'orders', label: 'Table', value: 'orders' }],
tagPlacement: 'inline',
},
renderComposerTagTooltip: () => 'Details',
});
expect(warn).toHaveBeenCalledWith(
'[WebShell] inline tag tooltip render failed',
error,
);
const chip = document.body.querySelector('[title="Details"]');
expect(chip).not.toBeNull();
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
dispatchOnFailingChip?.(new MouseEvent('mouseenter'));
expect(failingTooltip?.style.display).toBe('none');
} finally {
warn.mockRestore();
appendChildSpy.mockRestore();
}
});
it('guards inline mask icon sources', async () => {
await mount({
composerInput: {

View file

@ -604,6 +604,8 @@ export const removeInlineTagEffect = StateEffect.define<{
}>();
export const clearInlineTagsEffect = StateEffect.define<void>();
let nextComposerTagTooltipId = 0;
class ComposerTagWidget extends WidgetType {
private contentRoot: Root | null = null;
private tooltipRoot: Root | null = null;
@ -634,7 +636,6 @@ class ComposerTagWidget extends WidgetType {
const publicTag = toPublicComposerTag(this.tag);
chip.style.cssText =
'position:relative;display:inline-flex;align-items:center;max-width:min(44ch,100%);min-height:20px;margin:0 0.25ch;border:1px solid var(--border);border-radius:4px;background:var(--secondary);color:var(--foreground);font-family:var(--font-mono,monospace);font-size:12px;line-height:1.2;vertical-align:baseline;';
if (this.tag.tooltipText) chip.title = this.tag.tooltipText;
if (this.tag.onClick) {
chip.setAttribute('role', 'button');
chip.tabIndex = 0;
@ -760,6 +761,21 @@ class ComposerTagWidget extends WidgetType {
tooltipElement.setAttribute('role', 'tooltip');
tooltipElement.style.cssText =
'position:absolute;z-index:calc(var(--web-shell-tooltip-z-index,1000) + 1);top:calc(100% + 6px);left:0;display:none;min-width:160px;max-width:min(320px,80vw);padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--background);box-shadow:0 8px 24px rgba(0,0,0,0.18);color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:12px;line-height:1.5;white-space:normal;';
try {
this.tooltipRoot = createRoot(tooltipElement);
this.tooltipRoot.render(tooltip);
chip.appendChild(tooltipElement);
tooltipElement.id = `composer-tag-tooltip-${++nextComposerTagTooltipId}`;
chip.setAttribute('aria-describedby', tooltipElement.id);
} catch (error) {
this.tooltipRoot?.unmount();
this.tooltipRoot = null;
if (this.tag.tooltipText) {
chip.title = this.tag.tooltipText;
}
console.warn('[WebShell] inline tag tooltip render failed', error);
return;
}
const show = () => {
tooltipElement.style.display = 'block';
};
@ -770,15 +786,6 @@ class ComposerTagWidget extends WidgetType {
chip.addEventListener('mouseleave', hide);
chip.addEventListener('focusin', show);
chip.addEventListener('focusout', hide);
try {
this.tooltipRoot = createRoot(tooltipElement);
this.tooltipRoot.render(tooltip);
chip.appendChild(tooltipElement);
} catch (error) {
this.tooltipRoot?.unmount();
this.tooltipRoot = null;
console.warn('[WebShell] inline tag tooltip render failed', error);
}
}
private appendRemoveButton(chip: HTMLElement, view: EditorView) {