Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/tooltip-popover-manual-coexistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@adobe/spectrum-wc': patch
'@adobe/spectrum-wc-core': patch
---

Tooltip now uses `popover="manual"` instead of `popover="auto"`. Opening a tooltip on hover no longer light-dismisses an open `<swc-popover>` (or menu, picker, or select); a hover tooltip and an open popover coexist in both directions. Escape and close-on-leave are handled internally, so no dismissal behavior is lost. Tooltip also joins the shared dismissible stack, so when a tooltip is open on top of a popover, Escape closes only the tooltip first and leaves the popover open; a second Escape closes the popover.
74 changes: 53 additions & 21 deletions 2nd-gen/packages/core/components/tooltip/Tooltip.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';

import { SpectrumElement } from '@adobe/spectrum-wc-core/element/index.js';
import {
isTopDismissible,
registerDismissible,
unregisterDismissible,
} from '@adobe/spectrum-wc-core/utils/index.js';

import {
HoverController,
Expand Down Expand Up @@ -220,10 +225,13 @@ export abstract class TooltipBase
}

// Reflects the browser's actual popover state. Used in updated() to reconcile
// `open` against native light-dismiss: when the browser closes the popover
// (e.g. Escape or outside click), `open` is synced by the toggle listener
// without re-invoking the Popover API, and the guard prevents a redundant
// hidePopover() (which would throw on an already-closed popover).
// `open` against the native popover state: when a `toggle` event syncs `open`
// (the toggle listener sets it without re-invoking the Popover API), this
// guard prevents a redundant showPopover()/hidePopover() call in the following
// update (hidePopover() would throw on an already-closed popover). Under
// `popover="manual"` the browser never closes the tooltip on its own, so the
// only driver is the component itself, but the guard is still needed to keep
// the `open`-driven and toggle-driven paths from double-invoking the API.
private get isPopoverOpen(): boolean {
return this.matches(':popover-open');
}
Expand Down Expand Up @@ -446,18 +454,23 @@ export abstract class TooltipBase
this.dispatchAfterEvent(this.open);
};

// Escape-to-close. Registered on `document` only while open (see updated()),
// not for the whole connected lifetime: the tooltip is non-interactive and
// never receives focus, so a host-level keydown would not fire, and scoping to
// the open state keeps at most one listener active at a time (popover="auto"
// permits one open tooltip). Under `popover="auto"` this duplicates the
// browser's native Escape light-dismiss; it is kept as a mode-independent
// handler so a `manual` tooltip (which gets no native light-dismiss, e.g. one
// that coexists with an open popover) still closes on Escape.
// Escape-to-close. Registered on `document` (capture) only while open (see
// updated()); `popover="manual"` gets no native light-dismiss, so this is the
// sole Escape mechanism.
private readonly handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && this.open) {
this.open = false;
if (event.key !== 'Escape' || !this.open) {
return;
}
// Only the topmost dismissible handles Escape; a surface above us gets it first.
if (!isTopDismissible(this)) {
return;
}
// A `manual` tooltip is outside the browser's auto-popover stack, so cancel
// the native default (capture phase, before it runs) to keep an `auto`
// popover underneath open; a later Escape, once we are gone, closes it.
event.preventDefault();
event.stopPropagation();
this.open = false;
};

protected override willUpdate(changedProperties: PropertyValues): void {
Expand Down Expand Up @@ -501,8 +514,13 @@ export abstract class TooltipBase
const openChanged = changedProperties.has('open');
if (openChanged) {
if (this.open) {
// Register Escape handling only while open; removed on close below.
document.addEventListener('keydown', this.handleKeyDown);
// Join the dismissible stack and Escape handling while open (both torn
// down on close below). Capture phase so preventDefault() beats the
// native popover light-dismiss (see handleKeyDown).
registerDismissible(this);
document.addEventListener('keydown', this.handleKeyDown, {
capture: true,
});
// Set actual-placement to the declared side synchronously, before
// showPopover(). @starting-style is evaluated by the browser the moment
// the popover enters the top layer, so the direction-bearing attribute
Expand All @@ -519,7 +537,10 @@ export abstract class TooltipBase
this.showPopover();
}
} else {
document.removeEventListener('keydown', this.handleKeyDown);
unregisterDismissible(this);
document.removeEventListener('keydown', this.handleKeyDown, {
capture: true,
});
if (this.open !== this.isPopoverOpen) {
this.hidePopover();
}
Expand All @@ -546,7 +567,15 @@ export abstract class TooltipBase
public override connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'tooltip');
this.setAttribute('popover', 'auto');
// `manual`, not `auto`: a tooltip must coexist with an open `swc-popover`
// (or menu/picker/select). Under `popover="auto"` the native light-dismiss
// group closes every other open auto popover the moment a tooltip opens on
// hover, tearing down an open popover the user is still working in. `manual`
// takes the tooltip out of that group. The behaviors `manual` drops are
// already handled internally: Escape via handleKeyDown (registered while
// open) and close-on-leave via HoverController; show/hide is driven
// explicitly through showPopover()/hidePopover() in updated().
this.setAttribute('popover', 'manual');
this.addEventListener('beforetoggle', this.handleBeforeToggle);
this.addEventListener('toggle', this.handleToggle);
this.addEventListener('transitionend', this.handleTransitionEnd);
Expand All @@ -557,9 +586,12 @@ export abstract class TooltipBase
this.removeEventListener('beforetoggle', this.handleBeforeToggle);
this.removeEventListener('toggle', this.handleToggle);
this.removeEventListener('transitionend', this.handleTransitionEnd);
// Defensive: the keydown listener is normally removed on close, but a
// tooltip disconnected while open would still have it registered.
document.removeEventListener('keydown', this.handleKeyDown);
// Defensive: clear the stack entry and keydown listener in case the tooltip
// is disconnected while still open (normally cleared on close).
unregisterDismissible(this);
document.removeEventListener('keydown', this.handleKeyDown, {
capture: true,
});
if (this.afterEventFallbackTimer !== null) {
clearTimeout(this.afterEventFallbackTimer);
this.afterEventFallbackTimer = null;
Expand Down
2 changes: 1 addition & 1 deletion 2nd-gen/packages/swc/components/popover/popover.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The arrow (tip) points at the trigger and is shown by default. Set `hide-arrow`

`open` is the single source of truth for visibility: the popover is either open or closed. Toggling the trigger, pressing `Escape`, or clicking outside (default mode) all reconcile back to `open`. There are no disabled or selected states.

Because the default mode uses `popover="auto"`, opening a popover dismisses any other open auto popover in the document, including tooltips, menus, pickers, and other popovers. Nested popovers are the exception: a popover opened from inside another forms an ancestor chain and stays open together.
Because the default mode uses `popover="auto"`, opening a popover dismisses any other open auto popover in the document, including menus, pickers, and other popovers. Tooltips are the exception among top-layer surfaces: `<swc-tooltip>` uses `popover="manual"`, so a hover tooltip coexists with an open popover rather than light-dismissing it or being light-dismissed by it (the tooltip still closes on its own triggers, such as the pointer leaving the trigger). Nested popovers are also exempt: a popover opened from inside another forms an ancestor chain and stays open together.

<Canvas of={Stories.States} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ With `manual`, ARIA relationship wiring via `for` still fires on every `open` ch
- **No interactive content in tooltips:** `role="tooltip"` prohibits interactive elements (links, buttons, inputs) inside the tooltip. Refactor those patterns to `<swc-popover>` or a dialog component.
- **Non-interactive triggers:** Tooltips must be attached to focusable elements. Static text, decorative icons, and non-interactive elements are not valid tooltip triggers; use contextual help instead.
- **Touch and mobile:** `<swc-tooltip>` is hover/focus only. For touch-accessible disclosure, use `<swc-popover>` or contextual help.
- **`popover="auto"` auto-stack change:** Opening a `<swc-tooltip>` closes other open `auto` popovers (menus, pickers). This differs from the Spectrum 1 `type="hint"` isolation behavior, which left menus and pickers open. This is expected behavior, not a bug.
- **Popover coexistence:** `<swc-tooltip>` uses `popover="manual"`, so it stays outside the browser's auto light-dismiss group: opening a tooltip does not dismiss an open `<swc-popover>`, menu, or picker, and opening one of those does not force the tooltip closed (it still closes on its own triggers: pointer leave, focus out, or Escape). The tooltip joins the shared dismissible stack, so when it is open on top of a popover, Escape closes the tooltip first and leaves the popover open; a second Escape closes the popover.

## Styling

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '@adobe/spectrum-wc-core/components/tooltip';

import '@adobe/spectrum-wc/components/button/swc-button.js';
import '@adobe/spectrum-wc/components/popover/swc-popover.js';
import '@adobe/spectrum-wc/components/tooltip/swc-tooltip.js';

// ────────────────
Expand Down Expand Up @@ -574,3 +575,25 @@ export const Accessibility: Story = {
},
tags: ['a11y'],
};

// Hidden fixture (not in docs) for the trusted-input Escape-ordering a11y spec,
// which needs a popover and a tooltip registered in one iframe. The ci-a11y
// Storybook builds `*.stories.ts` but not the `.test.ts` fixtures, so this lives
// here rather than in tooltip.test.ts. `!test` keeps it out of the automatic axe
// run; the spec drives it explicitly by story id.
export const CoexistenceWithPopover: Story = {
render: () => html`
<swc-button id="coexist-tooltip-trigger">Trigger</swc-button>
<swc-tooltip for="coexist-tooltip-trigger" placement="top">
Tooltip text
</swc-tooltip>
<swc-button id="coexist-popover-trigger">Open popover</swc-button>
<swc-popover
for="coexist-popover-trigger"
accessible-label="Coexistence popover"
>
Popover content
</swc-popover>
`,
tags: ['!test'],
};
108 changes: 105 additions & 3 deletions 2nd-gen/packages/swc/components/tooltip/test/tooltip.a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ test.describe('Tooltip - ARIA Snapshots', () => {
`);
});

// Trusted (Playwright) Escape exercises the real native `popover="auto"` dismissal.
// The synthetic-input counterpart (JS `handleKeyDown` backstop) is EscapeClosesTest in
// tooltip.test.ts; trusted input cannot run in a dev-indexed play function, so it lives here.
// `popover="manual"` has no native Escape light-dismiss, so closing on Escape
// is entirely the component's own document `handleKeyDown`. This exercises it
// under trusted (Playwright) input; EscapeClosesTest in tooltip.test.ts covers
// the synthetic-input path. Trusted input cannot run in a dev-indexed play
// function, so this case lives here.
test('Escape closes an open tooltip', async ({ page }) => {
await gotoStory(page, 'components-tooltip--overview', 'swc-button');

Expand Down Expand Up @@ -130,6 +132,106 @@ test.describe('Tooltip - ARIA Snapshots', () => {
expect(open, 'tooltip.open is false after Escape').toBe(false);
});

// Escape must dismiss the dismissible stack in reverse open order (LIFO),
// whichever surface is on top. Only trusted (Playwright) input drives the
// popover's native light-dismiss, so this cross-mechanism ordering can't run in
// a synthetic play function. Both orders share the CoexistenceWithPopover
// fixture and differ only in which surface is opened last (topmost).
type Surface = 'popover' | 'tooltip';
const orderedScenarios: Array<{
name: string;
openOrder: readonly Surface[];
}> = [
{
name: 'tooltip opened over a popover',
openOrder: ['popover', 'tooltip'],
},
{
name: 'popover opened over a tooltip',
openOrder: ['tooltip', 'popover'],
},
];

// Read visibility per surface: the popover via its reconciled `open` (its host
// is `display: contents`, so it never matches `:popover-open`), the tooltip via
// `:popover-open` on the host.
const surfaceIsOpen = (kind: Surface): boolean => {
const el = document.querySelector(`swc-${kind}`);
return kind === 'tooltip'
? el?.matches(':popover-open') === true
: (el as (Element & { open?: boolean }) | null)?.open === true;
};

for (const { name, openOrder } of orderedScenarios) {
// Escape dismisses in reverse open order: the last-opened surface registers
// into the dismissible stack last, so it is topmost and closes first.
const closeOrder = [...openOrder].reverse();

test(`Escape dismisses in reverse open order: ${name}`, async ({
page,
}) => {
await gotoStory(
page,
'components-tooltip--coexistence-with-popover',
'swc-button'
);

// Drive open state explicitly and in a fixed order so the assertion does
// not depend on the story's play-function timing.
await page.evaluate(
async (order: Surface[]) => {
for (const kind of order) {
const el = document.querySelector(`swc-${kind}`) as
| (HTMLElement & {
open: boolean;
updateComplete: Promise<unknown>;
})
| null;
if (!el) {
continue;
}
el.open = true;
await el.updateComplete;
}
},
[...openOrder]
);
await page.waitForFunction(
() =>
(document.querySelector('swc-popover') as { open?: boolean })
?.open === true &&
document.querySelector('swc-tooltip')?.matches(':popover-open') ===
true
);

// One Escape per surface: each must close exactly the topmost remaining
// surface and leave every surface still below it open.
for (let i = 0; i < closeOrder.length; i++) {
const closing = closeOrder[i];
const stillOpen = closeOrder.slice(i + 1);

await page.keyboard.press('Escape');
await page.waitForFunction((kind: Surface) => {
const el = document.querySelector(`swc-${kind}`);
return kind === 'tooltip'
? !el?.matches(':popover-open')
: (el as (Element & { open?: boolean }) | null)?.open === false;
}, closing);

expect(
await page.evaluate(surfaceIsOpen, closing),
`${closing} closed on Escape #${i + 1}`
).toBe(false);
for (const other of stillOpen) {
expect(
await page.evaluate(surfaceIsOpen, other),
`${other} stays open after Escape #${i + 1}`
).toBe(true);
}
}
});
}

test('all variant triggers are accessible', async ({ page }) => {
const root = await gotoStory(
page,
Expand Down
Loading
Loading