diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index bb787a79e0..289adf824b 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -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: { diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 8a2aa86284..ed13529174 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -604,6 +604,8 @@ export const removeInlineTagEffect = StateEffect.define<{ }>(); export const clearInlineTagsEffect = StateEffect.define(); +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) {