Skip to content

Commit a627684

Browse files
authored
Merge branch 'main' into SWC-427
2 parents d609e1d + 4cffef3 commit a627684

File tree

3 files changed

+766
-30
lines changed

3 files changed

+766
-30
lines changed

packages/overlay/src/HoverController.ts

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,32 @@ import {
2020
lastInteractionType,
2121
} from './InteractionController.js';
2222

23-
const HOVER_DELAY = 300;
24-
2523
export class HoverController extends InteractionController {
2624
override type = InteractionTypes.hover;
2725

2826
private elementIds: string[] = [];
2927

30-
focusedin = false;
28+
private targetFocused = false;
3129

3230
private hoverTimeout?: ReturnType<typeof setTimeout>;
3331

34-
pointerentered = false;
32+
private hovering = false;
33+
34+
private overlayFocused = false;
3535

3636
handleKeyup(event: KeyboardEvent): void {
37-
if (event.code === 'Tab' || event.code === 'Escape') {
37+
if (event.code === 'Tab') {
3838
this.open = true;
39+
} else if (event.code === 'Escape') {
40+
if (this.open) {
41+
event.preventDefault();
42+
event.stopPropagation();
43+
this.open = false;
44+
// Return focus to trigger element
45+
if (this.target) {
46+
this.target.focus();
47+
}
48+
}
3949
}
4050
}
4151

@@ -52,23 +62,29 @@ export class HoverController extends InteractionController {
5262
}
5363

5464
this.open = true;
55-
this.focusedin = true;
65+
this.targetFocused = true;
5666
}
5767

5868
handleTargetFocusout(): void {
59-
this.focusedin = false;
60-
if (this.pointerentered) return;
61-
this.open = false;
69+
this.targetFocused = false;
70+
// Don't close immediately if pointer is over the content
71+
if (this.hovering) return;
72+
// Use delay to allow focus to move into overlay content
73+
this.doFocusleave();
6274
}
6375

64-
handleTargetPointerenter(): void {
76+
private clearCloseTimeout(): void {
6577
if (this.hoverTimeout) {
6678
clearTimeout(this.hoverTimeout);
6779
this.hoverTimeout = undefined;
6880
}
81+
}
82+
83+
handleTargetPointerenter(): void {
84+
this.clearCloseTimeout();
6985
if (this.overlay?.disabled) return;
7086
this.open = true;
71-
this.pointerentered = true;
87+
this.hovering = true;
7288
}
7389

7490
handleTargetPointerleave(): void {
@@ -78,16 +94,28 @@ export class HoverController extends InteractionController {
7894
// set a timeout once the pointer enters and the overlay is shown
7995
// give the user time to enter the overlay
8096
handleHostPointerenter(): void {
81-
if (this.hoverTimeout) {
82-
clearTimeout(this.hoverTimeout);
83-
this.hoverTimeout = undefined;
84-
}
97+
this.clearCloseTimeout();
8598
}
8699

87100
handleHostPointerleave(): void {
88101
this.doPointerleave();
89102
}
90103

104+
handleOverlayFocusin(): void {
105+
this.overlayFocused = true;
106+
// Clear any pending close timeout when focus enters overlay
107+
this.clearCloseTimeout();
108+
}
109+
110+
handleOverlayFocusout(): void {
111+
this.overlayFocused = false;
112+
// Don't close immediately if pointer is over the content or trigger has focus
113+
if (this.hovering) return;
114+
if (this.targetFocused && this.target.matches(':focus-visible')) return;
115+
// Use delay before closing
116+
this.doFocusleave();
117+
}
118+
91119
override prepareDescription(): void {
92120
// require "content" to apply relationship
93121
if (!this.overlay.elements.length) return;
@@ -138,14 +166,31 @@ export class HoverController extends InteractionController {
138166
};
139167
}
140168

141-
protected doPointerleave(): void {
142-
this.pointerentered = false;
143-
const triggerElement = this.target as HTMLElement;
144-
if (this.focusedin && triggerElement.matches(':focus-visible')) return;
145-
169+
private scheduleClose(): void {
146170
this.hoverTimeout = setTimeout(() => {
147171
this.open = false;
148-
}, HOVER_DELAY);
172+
}, 300);
173+
}
174+
175+
private doPointerleave(): void {
176+
this.hovering = false;
177+
const triggerElement = this.target as HTMLElement;
178+
if (this.targetFocused && triggerElement.matches(':focus-visible'))
179+
return;
180+
// Don't close if focus is within overlay content
181+
if (this.overlayFocused) return;
182+
183+
this.scheduleClose();
184+
}
185+
186+
private doFocusleave(): void {
187+
// Clear any existing timeout
188+
this.clearCloseTimeout();
189+
190+
// Use same delay as pointer interactions for consistency
191+
if (!this.targetFocused && !this.overlayFocused && !this.hovering) {
192+
this.scheduleClose();
193+
}
149194
}
150195

151196
override init(): void {
@@ -198,5 +243,20 @@ export class HoverController extends InteractionController {
198243
() => this.handleHostPointerleave(),
199244
{ signal }
200245
);
246+
this.overlay.addEventListener(
247+
'focusin',
248+
() => this.handleOverlayFocusin(),
249+
{ signal }
250+
);
251+
this.overlay.addEventListener(
252+
'focusout',
253+
() => this.handleOverlayFocusout(),
254+
{ signal }
255+
);
256+
this.overlay.addEventListener(
257+
'keyup',
258+
(event) => this.handleKeyup(event),
259+
{ signal }
260+
);
201261
}
202262
}

packages/overlay/stories/overlay.stories.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import '@spectrum-web-components/dialog/sp-dialog.js';
2020
import '@spectrum-web-components/field-label/sp-field-label.js';
2121
import '@spectrum-web-components/icons-workflow/icons/sp-icon-magnify.js';
2222
import '@spectrum-web-components/icons-workflow/icons/sp-icon-open-in.js';
23+
import '@spectrum-web-components/link/sp-link.js';
2324
import {
2425
openOverlay,
2526
Overlay,
@@ -1654,6 +1655,92 @@ export const triggeredByOptimization = (): TemplateResult => {
16541655
`;
16551656
};
16561657

1658+
export const hoverWithInteractiveContent = (): TemplateResult => {
1659+
return html`
1660+
<div
1661+
style="display: flex; gap: 20px; flex-direction: column; padding: 40px;"
1662+
>
1663+
<!-- Hover with interactive buttons -->
1664+
<overlay-trigger triggered-by="hover" placement="right">
1665+
<sp-button slot="trigger">
1666+
Hover for interactive buttons
1667+
</sp-button>
1668+
<sp-popover slot="hover-content" tip>
1669+
<sp-dialog size="s" no-divider>
1670+
<h3 style="margin-top: 0;">Interactive content</h3>
1671+
<p>Tab into these buttons:</p>
1672+
<div
1673+
style="display: flex; gap: 8px; flex-direction: column;"
1674+
>
1675+
<sp-button>Action 1</sp-button>
1676+
<sp-button>Action 2</sp-button>
1677+
<sp-button>Action 3</sp-button>
1678+
</div>
1679+
</sp-dialog>
1680+
</sp-popover>
1681+
</overlay-trigger>
1682+
1683+
<!-- Hover with links -->
1684+
<overlay-trigger triggered-by="hover" placement="right">
1685+
<sp-button slot="trigger">
1686+
Hover for interactive links
1687+
</sp-button>
1688+
<sp-popover slot="hover-content" tip>
1689+
<sp-dialog size="s" no-divider>
1690+
<h3 style="margin-top: 0;">Quick links</h3>
1691+
<ul>
1692+
<li>
1693+
<sp-link href="#example1">
1694+
Example link 1
1695+
</sp-link>
1696+
</li>
1697+
<li>
1698+
<sp-link href="#example2">
1699+
Example link 2
1700+
</sp-link>
1701+
</li>
1702+
<li>
1703+
<sp-link href="#example3">
1704+
Example link 3
1705+
</sp-link>
1706+
</li>
1707+
</ul>
1708+
</sp-dialog>
1709+
</sp-popover>
1710+
</overlay-trigger>
1711+
1712+
<!-- Hover with action group (like Arrange icon example) -->
1713+
<overlay-trigger triggered-by="hover" placement="right">
1714+
<sp-button slot="trigger">Hover for action group</sp-button>
1715+
<sp-popover slot="hover-content" tip>
1716+
<sp-action-group
1717+
selects="single"
1718+
vertical
1719+
style="margin: var(--spectrum-spacing-200);"
1720+
>
1721+
<sp-action-button>
1722+
<sp-icon-magnify slot="icon"></sp-icon-magnify>
1723+
Send to Front
1724+
</sp-action-button>
1725+
<sp-action-button>
1726+
<sp-icon-magnify slot="icon"></sp-icon-magnify>
1727+
Send to Back
1728+
</sp-action-button>
1729+
<sp-action-button>
1730+
<sp-icon-magnify slot="icon"></sp-icon-magnify>
1731+
Align Center
1732+
</sp-action-button>
1733+
</sp-action-group>
1734+
</sp-popover>
1735+
</overlay-trigger>
1736+
</div>
1737+
`;
1738+
};
1739+
1740+
hoverWithInteractiveContent.swc_vrt = {
1741+
skip: true,
1742+
};
1743+
16571744
export const pickerInDialog = (): TemplateResult => {
16581745
return html`
16591746
<sp-button variant="primary" id="mybutton">Button popover</sp-button>

0 commit comments

Comments
 (0)