Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/web-shell/client/hooks/useComposerCore.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Comment thread
dreamWB marked this conversation as resolved.
Resolved
});

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,
);
Comment thread
dreamWB marked this conversation as resolved.
Resolved
});

it('falls back to a native title when attaching an inline tooltip fails', async () => {
Comment thread
dreamWB marked this conversation as resolved.
Resolved
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: {
Expand Down
27 changes: 17 additions & 10 deletions packages/web-shell/client/hooks/useComposerCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,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;
Expand Down Expand Up @@ -631,7 +633,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;
Expand Down Expand Up @@ -757,6 +758,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}`;
Comment thread
dreamWB marked this conversation as resolved.
Resolved
chip.setAttribute('aria-describedby', tooltipElement.id);
Comment thread
dreamWB marked this conversation as resolved.
Resolved
} catch (error) {
Comment thread
dreamWB marked this conversation as resolved.
Resolved
this.tooltipRoot?.unmount();
this.tooltipRoot = null;
if (this.tag.tooltipText) {
Comment thread
dreamWB marked this conversation as resolved.
Resolved
chip.title = this.tag.tooltipText;
}
console.warn('[WebShell] inline tag tooltip render failed', error);
return;
}
const show = () => {
tooltipElement.style.display = 'block';
};
Expand All @@ -767,15 +783,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) {
Expand Down
Loading